Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ dependencies {
implementation(projects.layouteditor)
implementation(projects.idetooltips)
implementation(projects.composePreview)
implementation(projects.floatingWindow)
implementation(projects.gitCore)

// This is to build the tooling-api-impl project before the app is built
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,13 @@ import com.itsaky.androidide.models.DiagnosticGroup
import com.itsaky.androidide.models.OpenedFile
import com.itsaky.androidide.models.Range
import com.itsaky.androidide.models.SearchResult
import com.itsaky.androidide.plugins.extensions.FileTabMenuItem
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
import com.itsaky.androidide.preferences.internal.BuildPreferences
import com.itsaky.androidide.preferences.internal.GeneralPreferences
import com.itsaky.androidide.projects.IProjectManager
import com.itsaky.androidide.projects.ProjectManagerImpl
import com.itsaky.androidide.resources.R as ResR
import com.itsaky.androidide.services.debug.DebuggerService
import com.itsaky.androidide.tasks.cancelIfActive
import com.itsaky.androidide.ui.CodeEditorView
Expand Down Expand Up @@ -1038,10 +1040,18 @@ abstract class BaseEditorActivity :
}

val pluginMenuItems = if (this is EditorHandlerActivity) {
val self = this
val fileIndex = getFileIndexForTabPosition(position)
if (fileIndex >= 0) {
val file = editorViewModel.getOpenedFile(fileIndex)
IDEApplication.getPluginManager()?.getFileTabMenuItems(file) ?: emptyList()
val pluginItems =
IDEApplication.getPluginManager()?.getFileTabMenuItems(file) ?: emptyList()
listOf(
FileTabMenuItem(
id = "ide.floating.undock",
title = getString(R.string.undock),
) { self.undockFileTab(fileIndex) },
) + pluginItems
} else {
emptyList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,17 @@ open class EditorHandlerActivity :
pluginEditorProvider = null
}

private val floatingTabController by lazy {
com.itsaky.androidide.editor.floating.IdeFloatingTabController(this)
}

override fun onCreate(savedInstanceState: Bundle?) {
setupPluginFragmentFactory()
mBuildEventListener.setActivity(this)
super.onCreate(savedInstanceState)

supportFragmentManager.registerFragmentLifecycleCallbacks(pluginFontScalingListener, true)
floatingTabController.start()

editorViewModel._displayedFile.observe(
this,
Expand Down Expand Up @@ -587,6 +592,21 @@ open class EditorHandlerActivity :
return if (child is CodeEditorView) child else null
}

/** Undock the file tab at [fileIndex] into a floating window over other apps. */
fun undockFileTab(fileIndex: Int) {
floatingTabController.undock(fileIndex)
}

/** Undock the plugin tab [tabId] (at [position]) into a floating window over other apps. */
fun undockPluginTab(tabId: String, position: Int) {
val title =
com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
.getInstance()
.getPluginTab(tabId)
?.title ?: tabId
floatingTabController.floatPluginTab(tabId, title) { closePluginTab(position) }
}

override fun openFileAndSelect(
file: File,
selection: Range?,
Expand Down Expand Up @@ -1565,6 +1585,27 @@ open class EditorHandlerActivity :
}

binding.root.addView(closeItem)

val undockItem =
FileActionPopupWindowItemBinding
.inflate(
android.view.LayoutInflater.from(this),
null,
false,
).root
undockItem.apply {
text = getString(string.undock)
setOnClickListener {
val pos = tab.position
val tabId = getPluginTabId(pos)
if (tabId != null) {
undockPluginTab(tabId, pos)
}
popupWindow.dismiss()
}
}
binding.root.addView(undockItem)

popupWindow.showAsDropDown(anchorView, 0, 0)
}

Expand Down
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
}
Comment on lines +75 to +78

Copy link
Copy Markdown
Contributor

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 via onDestroyView(). 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt`
around lines 75 - 78, The teardown currently only happens in release(), but the
floating-window lifecycle expects EditorPanelDockableContent to clean up in
onDestroyView(). Add the DockableContent.onDestroyView() implementation to
invoke the same CodeEditorView.close() and null-out logic used by release(), so
the editor is always released even when the overlay is destroyed outside the
redock flow.

}
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 kotlin

Repository: 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 -100

Repository: 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).");
JS

Repository: appdevforall/CodeOnTheGo

Length of output: 335


🏁 Script executed:

cat -n ./app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt | head -50

Repository: appdevforall/CodeOnTheGo

Length of output: 2242


Do not bind docking-event reconciliation to the activity lifecycle.

DockingManager is a singleton, and its events property is a MutableSharedFlow configured with extraBufferCapacity = 16 but no replay history (replay=0).

Since IdeFloatingTabController is instantiated per EditorHandlerActivity and collects events within activity.lifecycleScope, any Redock or Close event emitted by the floating window service while the activity is destroyed (e.g., configuration change, background navigation) or before the new instance starts will be lost forever. The new controller instance will not receive these past events, causing the editor state to drift from the floating window state.

Decouple the collection from the activity lifecycle (e.g., move to Application scope) or configure the MutableSharedFlow with replay = 1 to ensure the latest event is delivered to new subscribers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt`
around lines 29 - 33, The event collection in IdeFloatingTabController.start is
tied to activity.lifecycleScope, so DockingManager.events can be missed when the
activity is recreated or inactive. Move the collection out of the activity
lifecycle (for example, use an application-wide scope) or change
DockingManager.events to a MutableSharedFlow with replay = 1 so new subscribers
still receive the latest Redock/Close event. Keep the fix centered around
start() and DockingManager.events.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Abort the undock if panel.save() fails.

This path clears the dirty state and closes the docked editor unconditionally. If save() returns false, the tab is still marked saved and removed, which can discard unsaved edits.

Suggested fix
 		activity.lifecycleScope.launch {
-			panel.save()
+			if (!panel.save()) {
+				return@launch
+			}
 			panel.markAsSaved()
 			activity.closeFile(fileIndex) {}
 			DockingManager.undock(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
activity.lifecycleScope.launch {
panel.save()
panel.markAsSaved()
activity.closeFile(fileIndex) {}
activity.lifecycleScope.launch {
if (!panel.save()) {
return@launch
}
panel.markAsSaved()
activity.closeFile(fileIndex) {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt`
around lines 44 - 47, The undock flow in IdeFloatingTabController.launch
currently proceeds to mark the panel saved and close the file unconditionally
after panel.save(). Update this path so the save result is checked first; if
panel.save() returns false, stop the coroutine flow and do not call
panel.markAsSaved() or activity.closeFile(...). Keep the existing save/close
sequence only when the save succeeds.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

remove() runs first, but the floating plugin content can still fail later during fragment creation/hosting. In that case the original tab is already gone and there is nothing left to redock back to.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt`
around lines 65 - 70, In IdeFloatingTabController’s floating-tab flow, the
original tab is being removed too early, before the floating copy is confirmed
to exist. Reorder the logic so remove() is only called after
DockingManager.undock and FloatingTabService.ensureRunning complete
successfully, or gate it behind a successful floating-host creation path, so the
docked tab can be restored if the floating setup fails.

}

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Treat a null plugin fragment as a load failure.

PluginEditorTabManager.newTabFragment(tabId) can return null, but this path still returns overlayHost.view. That leaves the user with an empty floating window instead of the fallback error view you already implemented.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt`
around lines 37 - 39, `PluginTabDockableContent.createContent` currently returns
`overlayHost.view` even when `PluginEditorTabManager.newTabFragment(tabId)` is
null, which bypasses the existing failure UI. Update the flow so the
`overlayHost` only returns its normal view after `setFragment` succeeds, and
otherwise switch to the fallback error view already implemented for load
failures. Keep the null-handling logic centered around `newTabFragment(tabId)`
and `overlayHost` so a missing plugin fragment is treated as an actual failure.

} 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"
}
}
41 changes: 41 additions & 0 deletions floating-window/build.gradle.kts
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)
}
Loading
Loading