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
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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

New more accurate name, please

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
11 changes: 11 additions & 0 deletions plugin-api/api/plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@ public final class com/itsaky/androidide/plugins/services/IdeCommandService$Defa
public abstract interface class com/itsaky/androidide/plugins/services/IdeEditorService {
public abstract fun addFileChangeListener (Lcom/itsaky/androidide/plugins/services/FileChangeListener;)V
public abstract fun appendToLine (Ljava/io/File;ILjava/lang/String;)Z
public abstract fun clearPeerCursors (Ljava/io/File;)V
public abstract fun deleteLine (Ljava/io/File;I)Z
public abstract fun getCurrentCursorPosition ()Lcom/itsaky/androidide/plugins/services/CursorPosition;
public abstract fun getCurrentFile ()Ljava/io/File;
Expand All @@ -1241,6 +1242,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor
public abstract fun getModifiedFiles ()Ljava/util/List;
public abstract fun getOpenFiles ()Ljava/util/List;
public abstract fun getWordAtCursor ()Ljava/lang/String;
public abstract fun hidePeerCursor (Ljava/io/File;Ljava/lang/String;)Z
public abstract fun insertLineBefore (Ljava/io/File;ILjava/lang/String;)Z
public abstract fun insertTextAtCursor (Ljava/lang/String;)Z
public abstract fun isFileModified (Ljava/io/File;)Z
Expand All @@ -1253,6 +1255,13 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor
public abstract fun replaceRange (Ljava/io/File;Lcom/itsaky/androidide/plugins/services/SelectionRange;Ljava/lang/String;)Z
public abstract fun replaceSelection (Ljava/lang/String;)Z
public abstract fun saveCurrentFile ()Z
public abstract fun showPeerCursor (Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z
}

public final class com/itsaky/androidide/plugins/services/IdeEditorService$DefaultImpls {
public static fun clearPeerCursors (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;)V
public static fun hidePeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;Ljava/lang/String;)Z
public static fun showPeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z
}

public abstract interface class com/itsaky/androidide/plugins/services/IdeEditorTabService {
Expand Down Expand Up @@ -1307,10 +1316,12 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeProjec
public abstract fun getCurrentProject ()Lcom/itsaky/androidide/plugins/extensions/IProject;
public abstract fun getModuleContext (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/ModuleContext;
public abstract fun getProjectByPath (Ljava/io/File;)Lcom/itsaky/androidide/plugins/extensions/IProject;
public abstract fun openProject (Ljava/io/File;)Z
}

public final class com/itsaky/androidide/plugins/services/IdeProjectService$DefaultImpls {
public static fun getModuleContext (Lcom/itsaky/androidide/plugins/services/IdeProjectService;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/ModuleContext;
public static fun openProject (Lcom/itsaky/androidide/plugins/services/IdeProjectService;Ljava/io/File;)Z
}

public abstract interface class com/itsaky/androidide/plugins/services/IdeSidebarService {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ interface IdeProjectService {
*/
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

/**
* Resolves the build context (compile/intermediate classpaths, runtime dex files,
* selected variant, resource APK, and whether a build is needed) for the module that
Expand Down Expand Up @@ -143,6 +161,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 @@ -64,6 +64,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 @@ -153,6 +154,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 @@ -186,6 +200,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 @@ -258,18 +273,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}")
val result = loadPlugin(pluginFile)
result.onFailure { error ->
logger.error("Failed to load plugin from ${pluginFile.name}: ${error.message}", error)
}
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 @@ -759,6 +776,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 @@ -1122,7 +1147,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 @@ -1356,7 +1382,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