Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.itsaky.androidide.activities.editor

import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.widget.TextView
import io.github.rosemoe.sora.widget.CodeEditor
import io.github.rosemoe.sora.widget.base.EditorPopupWindow
import java.io.File

/**
* Renders remote-collaborator presence as small named caret badges floating in the editor,
* one per (file, peerId). Markers are positioned in content coordinates and track scrolling
* via [EditorPopupWindow.FEATURE_SCROLL_AS_CONTENT]; the plugin repositions a marker by
* calling [addMarker] again with a new line/column on each cursor move.
*
* All methods must be called on the main thread (the editor view is touched directly).
*/
class PeerPresenceOverlayManager(
private val editorForFile: (File) -> CodeEditor?,
) {

private val markers: HashMap<String, HashMap<String, PeerCursorWindow>> = HashMap()

fun addMarker(
file: File,
line: Int,
column: Int,
peerId: String,
peerName: String,
peerColor: Int,
): Boolean {
val editor = editorForFile(file) ?: return false
val content = editor.text
if (line !in 0 until content.lineCount) return false
val safeColumn = column.coerceIn(0, content.getColumnCount(line))
val byPeer = markers.getOrPut(file.absolutePath) { HashMap() }
val existing = byPeer[peerId]
val window = if (existing != null && existing.boundEditor === editor) {
existing
} else {
existing?.dismiss()
PeerCursorWindow(editor).also { byPeer[peerId] = it }
}
window.update(peerName, peerColor, line, safeColumn)
return true
}

fun removeMarker(file: File, peerId: String): Boolean {
val removed = markers[file.absolutePath]?.remove(peerId) ?: return false
removed.dismiss()
return true
}

fun clear(file: File) {
markers.remove(file.absolutePath)?.values?.forEach { it.dismiss() }
}

fun clearAll() {
markers.values.forEach { byPeer -> byPeer.values.forEach { it.dismiss() } }
markers.clear()
}
}

class PeerCursorWindow(
val boundEditor: CodeEditor,
) : EditorPopupWindow(
boundEditor,
FEATURE_SCROLL_AS_CONTENT or FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED,
) {

private val density = boundEditor.context.resources.displayMetrics.density

private val label = TextView(boundEditor.context).apply {
textSize = 11f
gravity = Gravity.CENTER
maxLines = 1
includeFontPadding = false
val padH = (8 * density).toInt()
val padV = (3 * density).toInt()
setPadding(padH, padV, padH, padV)
}

init {
popup.isClippingEnabled = false
setContentView(label)
}

fun update(peerName: String, peerColor: Int, line: Int, column: Int) {
// getOffset returns the on-screen x. If the caret is past the visible width, add a
// direction arrow so the badge (clamped to the edge below) signals where the peer is.
val rawX = boundEditor.getOffset(line, column).toInt()
label.text = when {
rawX > boundEditor.width -> "$peerName →"
rawX < 0 -> "← $peerName"
else -> peerName
}
label.setTextColor(contrastingTextColor(peerColor))
label.background = GradientDrawable().apply {
setColor(peerColor)
cornerRadius = 4 * density
}

label.measure(
View.MeasureSpec.makeMeasureSpec(boundEditor.width, View.MeasureSpec.AT_MOST),
View.MeasureSpec.makeMeasureSpec(boundEditor.height, View.MeasureSpec.AT_MOST),
)
val width = label.measuredWidth
val height = label.measuredHeight
setSize(width, height)

// Clamp into the visible width so a caret scrolled off to the right pins at the edge
// instead of vanishing. Only clamp when the editor has a known width.
val maxX = boundEditor.width - width
val x = if (maxX >= 0) rawX.coerceIn(0, maxX) else rawX
val y = (boundEditor.rowHeight * line) - boundEditor.offsetY - height
setLocationAbsolutely(x, y)
if (!isShowing) show()
}

private fun contrastingTextColor(background: Int): Int {
val r = Color.red(background) / 255.0
val g = Color.green(background) / 255.0
val b = Color.blue(background) / 255.0
val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return if (luminance > 0.6) Color.parseColor("#0A0A0A") else Color.WHITE
}
}
24 changes: 24 additions & 0 deletions app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.itsaky.androidide.app
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.activities.editor.PeerPresenceOverlayManager
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import com.itsaky.androidide.models.Position
import com.itsaky.androidide.models.Range
Expand Down Expand Up @@ -36,6 +37,9 @@ class EditorProviderImpl(
private val activityRef = WeakReference(activity)
private val mainHandler = Handler(Looper.getMainLooper())
private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>()
private val peerPresenceOverlay = PeerPresenceOverlayManager { file ->
activity()?.getEditorForFile(file)?.editor
}

private val internalListener: (File?) -> Unit = { file ->
fileCallbacks.forEach { cb ->
Expand All @@ -57,6 +61,7 @@ class EditorProviderImpl(
fun dispose() {
EditorEvents.removeFileChangeListener(internalListener)
fileCallbacks.clear()
onMain { peerPresenceOverlay.clearAll(); true }
activityRef.clear()
}

Expand Down Expand Up @@ -257,6 +262,25 @@ class EditorProviderImpl(
true
}

override fun showPeerCursor(
file: File,
line: Int,
column: Int,
peerId: String,
peerName: String,
peerColor: Int,
): Boolean = onMain {
peerPresenceOverlay.addMarker(file, line, column, peerId, peerName, peerColor)
}

override fun hidePeerCursor(file: File, peerId: String): Boolean = onMain {
peerPresenceOverlay.removeMarker(file, peerId)
}

override fun clearPeerCursors(file: File) {
onMain { peerPresenceOverlay.clear(file); true }
}

/**
* Resolves the editor for [file], validates [line] (bounds differ between edits that
* mutate an existing line and those that insert a new one), and runs [block] inside a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ class PluginRepositoryImpl(
}

manager.loadPlugins()

if (manager.getPlugin(pluginId) == null) {
// The new package replaced the previous one but failed to load. Remove the broken
// artifact so subsequent loadPlugins() calls don't keep retrying it.
finalFile.delete()
throw IllegalStateException(
manager.getLoadError(pluginId)
?: "Plugin \"$pluginId\" was installed but failed to load."
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}.onFailure { exception ->
Log.e(TAG, "Failed to install plugin from file: ${pluginFile.absolutePath}", exception)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ interface IdeProjectService {
* @return The project at the given path, or null if not found
*/
fun getProjectByPath(path: File): IProject?

/**
* Requests the IDE to open the project rooted at [projectDir], replacing the project
* that is currently open. Dispatches asynchronously: a `true` return means the open was
* requested and the editor is being launched, not that the project has finished loading.
* Poll [getCurrentProject] to observe completion.
*
* Requires the FILESYSTEM_READ permission.
*
* Default-implemented (no-op, returns false) so adding it is a backward-compatible
* interface extension: existing implementers and any prebuilt plugin-api lib keep
* compiling; the host overrides it.
*
* @param projectDir The root directory of the project to open
* @return true if the open request was dispatched, false if it was rejected or the IDE
* has no foreground activity available to host the editor
*/
fun openProject(projectDir: File): Boolean = false
}

/**
Expand Down Expand Up @@ -128,6 +146,31 @@ interface IdeEditorService {

fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean

/**
* Draws (or moves) a remote peer's cursor — a small colored, named caret badge —
* inside the editor for [file] at the 0-based [line]/[column]. Cursors are keyed by
* [peerId]: calling again for the same (file, peerId) repositions the existing cursor.
* [peerColor] is an ARGB int. No-op (returns false) if the file isn't open in an editor.
* Visual overlay only — never mutates file content. Requires FILESYSTEM_READ.
*
* Default-implemented (no-op) so adding it is a backward-compatible interface extension:
* existing implementers and any prebuilt plugin-api lib keep compiling; the host overrides it.
*/
fun showPeerCursor(
file: File,
line: Int,
column: Int,
peerId: String,
peerName: String,
peerColor: Int,
): Boolean = false

/** Hides the cursor for [peerId] in [file], if present. Default-implemented no-op. */
fun hidePeerCursor(file: File, peerId: String): Boolean = false

/** Removes all remote peer cursors in [file]. Default-implemented no-op. */
fun clearPeerCursors(file: File) {}

fun addFileChangeListener(listener: FileChangeListener)

fun removeFileChangeListener(listener: FileChangeListener)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import com.itsaky.androidide.plugins.extensions.BuildActionExtension
import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager
import com.itsaky.androidide.actions.SidebarSlotManager
import com.itsaky.androidide.actions.SidebarSlotExceededException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -151,6 +152,19 @@ class PluginManager private constructor(
range: com.itsaky.androidide.plugins.services.SelectionRange,
newText: String,
): Boolean = current()?.replaceRange(file, range, newText) ?: false
override fun showPeerCursor(
file: File,
line: Int,
column: Int,
peerId: String,
peerName: String,
peerColor: Int,
): Boolean = current()?.showPeerCursor(file, line, column, peerId, peerName, peerColor) ?: false
override fun hidePeerCursor(file: File, peerId: String): Boolean =
current()?.hidePeerCursor(file, peerId) ?: false
override fun clearPeerCursors(file: File) {
current()?.clearPeerCursors(file)
}
override fun addFileChangeCallback(callback: (File?) -> Unit) {
pendingFileChangeCallbacks.add(callback)
current()?.addFileChangeCallback(callback)
Expand Down Expand Up @@ -184,6 +198,7 @@ class PluginManager private constructor(

private val loadedPlugins = ConcurrentHashMap<String, LoadedPlugin>()
private val pluginStates = ConcurrentHashMap<String, Boolean>()
private val loadFailures = ConcurrentHashMap<String, String>()
private val pluginRegistry = PluginRegistry(context)
private val securityManager = PluginSecurityManager()
private val serviceRegistry = ServiceRegistryImpl()
Expand Down Expand Up @@ -256,15 +271,20 @@ class PluginManager private constructor(

logger.info("Found ${pluginFiles.size} plugin files")

loadFailures.clear()

// Load plugins in parallel
val loadJobs = pluginFiles.map { pluginFile ->
async {
try {
logger.debug("Loading plugin: ${pluginFile.name}")
logger.debug("Loading plugin: ${pluginFile.name}")
val result = try {
loadPlugin(pluginFile)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error("Failed to load plugin from ${pluginFile.name}", e)
Result.failure(e)
}
result.onFailure { error -> recordLoadFailure(pluginFile, error) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -754,6 +774,14 @@ class PluginManager private constructor(
fun getPlugin(pluginId: String): IPlugin? {
return loadedPlugins[pluginId]?.plugin
}

fun getLoadError(pluginId: String): String? = loadFailures[pluginId]

private fun recordLoadFailure(pluginFile: File, error: Throwable) {
logger.error("Failed to load plugin from ${pluginFile.name}", error)
val id = loadAndValidate(pluginFile).getOrNull()?.first?.id ?: pluginFile.nameWithoutExtension
loadFailures[id] = error.message ?: error.toString()
}

fun getAllPlugins(): List<PluginInfo> {
return loadedPlugins.values.map { loadedPlugin ->
Expand Down Expand Up @@ -1117,7 +1145,8 @@ class PluginManager private constructor(
override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path)
override fun getAllowedPaths(): List<String> = validator.getAllowedPaths()
}
}
},
activityProvider = activityProvider
)
}

Expand Down Expand Up @@ -1335,7 +1364,8 @@ class PluginManager private constructor(
override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path)
override fun getAllowedPaths(): List<String> = validator.getAllowedPaths()
}
}
},
activityProvider = activityProvider
)
}

Expand Down
Loading
Loading