diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 8c36fc09f0..f5c12a4fef 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -92,6 +92,9 @@ import com.itsaky.androidide.fragments.output.ShareableOutputFragment import com.itsaky.androidide.fragments.sidebar.FileTreeFragment import com.itsaky.androidide.handlers.EditorActivityLifecyclerObserver import com.itsaky.androidide.handlers.LspHandler.registerLanguageServers +import com.itsaky.androidide.handlers.SnippetHandler.loadPluginSnippets +import com.itsaky.androidide.handlers.SnippetHandler.loadUserSnippets +import com.itsaky.androidide.handlers.SnippetHandler.refreshPluginSnippets import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.DiagnosticClickListener @@ -426,6 +429,8 @@ abstract class BaseEditorActivity : protected open fun preDestroy() { BuildOutputProvider.clearBottomSheet() + IDEApplication.getPluginManager()?.setSnippetRefreshListener(null) + Shizuku.removeBinderReceivedListener(shizukuBinderReceivedListener) if (isAtLeastR()) wadbConnectionViewModel.stop(this) @@ -614,6 +619,11 @@ abstract class BaseEditorActivity : this.optionsMenuInvalidator = Runnable { super.invalidateOptionsMenu() } + loadUserSnippets() + loadPluginSnippets() + IDEApplication.getPluginManager()?.setSnippetRefreshListener { pluginId -> + refreshPluginSnippets(pluginId) + } registerLanguageServers() onBackPressedDispatcher.addCallback(this, onBackPressedCallback) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt new file mode 100644 index 0000000000..654d55aebe --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt @@ -0,0 +1,67 @@ +package com.itsaky.androidide.handlers + +import com.itsaky.androidide.lsp.java.providers.snippet.JavaSnippetScope +import com.itsaky.androidide.lsp.snippets.DefaultSnippet +import com.itsaky.androidide.lsp.snippets.ISnippetScope +import com.itsaky.androidide.lsp.snippets.SnippetRegistry +import com.itsaky.androidide.plugins.extensions.SnippetContribution +import com.itsaky.androidide.lsp.snippets.UserSnippetLoader +import com.itsaky.androidide.lsp.xml.providers.snippet.XML_SNIPPET_SCOPES +import com.itsaky.androidide.plugins.manager.snippets.PluginSnippetManager +import org.slf4j.LoggerFactory + +object SnippetHandler { + + private val log = LoggerFactory.getLogger(SnippetHandler::class.java) + + fun loadUserSnippets() { + loadUserSnippetsForLanguage("java", JavaSnippetScope.entries.toTypedArray()) + loadUserSnippetsForLanguage("xml", XML_SNIPPET_SCOPES) + } + + + fun loadPluginSnippets() { + val allSnippets = PluginSnippetManager.getInstance().getAllSnippets() + allSnippets.forEach { (pluginId, contributions) -> + registerContributions(pluginId, contributions) + } + if (allSnippets.isNotEmpty()) { + log.info("Loaded plugin snippets from {} plugins", allSnippets.size) + } + } + + fun refreshPluginSnippets(pluginId: String) { + SnippetRegistry.unregisterPluginSnippets(pluginId) + val contributions = PluginSnippetManager.getInstance().refreshPlugin(pluginId) + registerContributions(pluginId, contributions) + } + + private fun registerContributions(pluginId: String, contributions: List) { + contributions.groupBy { it.language to it.scope }.forEach { (key, group) -> + val (language, scope) = key + val snippets = group.map { DefaultSnippet(it.prefix, it.description, it.body.toTypedArray()) } + SnippetRegistry.registerPluginSnippets(pluginId, language, scope, snippets) + } + } + + fun removePluginSnippets(pluginId: String) { + SnippetRegistry.unregisterPluginSnippets(pluginId) + } + + private fun loadUserSnippetsForLanguage( + language: String, + scopes: Array, + ) { + SnippetRegistry.clearUserSnippets(language) + val userSnippets = UserSnippetLoader.loadUserSnippets(language, scopes) + userSnippets.forEach { (scope, snippets) -> + if (snippets.isNotEmpty()) { + SnippetRegistry.registerUserSnippets(language, scope, snippets) + } + } + val total = userSnippets.values.sumOf { it.size } + if (total > 0) { + log.info("Loaded {} user snippets for {}", total, language) + } + } +} \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/utils/Environment.java b/common/src/main/java/com/itsaky/androidide/utils/Environment.java index 6b80b83c23..4d11e0fe2a 100755 --- a/common/src/main/java/com/itsaky/androidide/utils/Environment.java +++ b/common/src/main/java/com/itsaky/androidide/utils/Environment.java @@ -121,6 +121,7 @@ public final class Environment { public static File NDK_DIR; public static File TEMPLATES_DIR; + public static File SNIPPETS_DIR; public static String getArchitecture() { return IDEBuildConfigProvider.getInstance().getCpuAbiName(); @@ -191,6 +192,7 @@ public static void init(Context context) { NDK_DIR = new File(ANDROID_HOME, "ndk"); TEMPLATES_DIR = mkdirIfNotExists(new File(ANDROIDIDE_HOME, "templates")); + SNIPPETS_DIR = mkdirIfNotExists(new File(ANDROIDIDE_HOME, "snippets")); isInitialized.set(true); } diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt index 72ba79dbc3..4e846c5468 100644 --- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt @@ -24,6 +24,7 @@ import com.itsaky.androidide.tasks.executeAsyncProvideError import com.itsaky.androidide.utils.VMUtils import org.slf4j.LoggerFactory import java.io.IOException +import java.io.Reader import java.util.concurrent.ConcurrentHashMap /** @@ -56,6 +57,24 @@ object SnippetParser { } } + fun parseFromReader( + reader: Reader, + snippetFactory: (String, String, List) -> ISnippet = { prefix, desc, body -> + DefaultSnippet(prefix, desc, body.toTypedArray()) + }, + ): List { + val snippets = mutableListOf() + JsonReader(reader).use { + it.beginObject() + while (it.hasNext()) { + val prefix = it.nextName() + readSnippet(prefix, it, snippetFactory, snippets) + } + it.endObject() + } + return snippets + } + private fun readSnippets( lang: String, type: String, @@ -70,18 +89,10 @@ object SnippetParser { .open(assetsPath(lang, type)) .reader() } catch (e: IOException) { - // snippet file probably does not exist return@executeAsyncProvideError } - JsonReader(content).use { - it.beginObject() - while (it.hasNext()) { - val prefix = it.nextName() - readSnippet(prefix, it, snippetFactory, snippets) - } - it.endObject() - } + snippets.addAll(parseFromReader(content, snippetFactory)) }) { result, err -> if (result == null || err != null) { log.error("Failed to load '{}' snippets", type, err) diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt new file mode 100644 index 0000000000..f8e3721c39 --- /dev/null +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt @@ -0,0 +1,86 @@ +package com.itsaky.androidide.lsp.snippets + +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +object SnippetRegistry { + + private val lock = ReentrantReadWriteLock() + + private val builtIn = mutableMapOf>>() + private val user = mutableMapOf>>() + private val plugin = mutableMapOf>>>() + + fun registerBuiltIn(language: String, scope: String, snippets: List) { + lock.write { + builtIn.getOrPut(language) { mutableMapOf() }[scope] = snippets.toMutableList() + } + } + + fun registerUserSnippets(language: String, scope: String, snippets: List) { + lock.write { + user.getOrPut(language) { mutableMapOf() }[scope] = snippets.toMutableList() + } + } + + fun registerPluginSnippets( + pluginId: String, + language: String, + scope: String, + snippets: List, + ) { + lock.write { + plugin.getOrPut(pluginId) { mutableMapOf() } + .getOrPut(language) { mutableMapOf() }[scope] = snippets.toMutableList() + } + } + + fun unregisterPluginSnippets(pluginId: String) { + lock.write { + plugin.remove(pluginId) + } + } + + fun clearUserSnippets(language: String) { + lock.write { + user.remove(language) + } + } + + fun getSnippets(language: String, scope: String): List = lock.read { + val builtInSnippets = builtIn[language]?.get(scope).orEmpty() + val userSnippets = user[language]?.get(scope).orEmpty() + + val userPrefixes = userSnippets.map { it.prefix }.toSet() + val merged = builtInSnippets.filter { it.prefix !in userPrefixes }.toMutableList() + merged.addAll(userSnippets) + + plugin.values.forEach { langMap -> + langMap[language]?.get(scope)?.let { merged.addAll(it) } + } + + merged + } + + fun getSnippets(language: String, scopes: List): List { + return scopes.flatMap { getSnippets(language, it) } + } + + fun initBuiltIn(language: String, scopes: Array) { + val parsed = SnippetParser.parse(language, scopes) + lock.write { + parsed.forEach { (scope, snippets) -> + builtIn.getOrPut(language) { mutableMapOf() }[scope.filename] = snippets as MutableList + } + } + } + + fun clear() { + lock.write { + builtIn.clear() + user.clear() + plugin.clear() + } + } +} \ No newline at end of file diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt new file mode 100644 index 0000000000..e94e54c640 --- /dev/null +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt @@ -0,0 +1,42 @@ +package com.itsaky.androidide.lsp.snippets + +import com.google.gson.JsonParseException +import com.itsaky.androidide.utils.Environment +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +object UserSnippetLoader { + + private val log = LoggerFactory.getLogger(UserSnippetLoader::class.java) + + fun loadUserSnippets( + language: String, + scopes: Array, + ): Map> { + val langDir = getUserSnippetsDir(language) + if (!langDir.isDirectory) return emptyMap() + + return scopes.associate { scope -> + val file = File(langDir, "snippets.${scope.filename}.json") + val snippets = if (file.isFile) { + try { + SnippetParser.parseFromReader(file.reader()) + } catch (e: IOException) { + log.error("Failed to read user snippets from {}", file.absolutePath, e) + emptyList() + } catch (e: JsonParseException) { + log.error("Failed to parse user snippets from {}", file.absolutePath, e) + emptyList() + } + } else { + emptyList() + } + scope.filename to snippets + } + } + + fun getUserSnippetsDir(language: String): File { + return File(Environment.SNIPPETS_DIR, language) + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt index 0562b72a84..8a39b1b8b2 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt @@ -18,19 +18,16 @@ package com.itsaky.androidide.lsp.java.providers.snippet import com.itsaky.androidide.lsp.snippets.ISnippet -import com.itsaky.androidide.lsp.snippets.SnippetParser +import com.itsaky.androidide.lsp.snippets.SnippetRegistry -/** - * Repository to store various snippets for Java. - * - * @author Akash Yadav - */ object JavaSnippetRepository { - lateinit var snippets: Map> - private set + val snippets: Map> + get() = JavaSnippetScope.entries.associateWith { scope -> + SnippetRegistry.getSnippets("java", scope.filename) + } fun init() { - this.snippets = SnippetParser.parse("java", JavaSnippetScope.values()) + SnippetRegistry.initBuiltIn("java", JavaSnippetScope.entries.toTypedArray()) } } diff --git a/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt b/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt index acaac4c6d7..418601b3bd 100644 --- a/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt +++ b/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt @@ -18,19 +18,16 @@ package com.itsaky.androidide.lsp.xml.providers.snippet import com.itsaky.androidide.lsp.snippets.ISnippet -import com.itsaky.androidide.lsp.snippets.SnippetParser +import com.itsaky.androidide.lsp.snippets.SnippetRegistry -/** - * Repository for XML snippets. - * - * @author Akash Yadav - */ object XmlSnippetRepository { - lateinit var snippets: Map> - private set + val snippets: Map> + get() = XML_SNIPPET_SCOPES.associateWith { scope -> + SnippetRegistry.getSnippets("xml", scope.filename) + } fun init() { - this.snippets = SnippetParser.parse("xml", XML_SNIPPET_SCOPES) + SnippetRegistry.initBuiltIn("xml", XML_SNIPPET_SCOPES) } } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/SnippetExtension.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/SnippetExtension.kt new file mode 100644 index 0000000000..64d498f1c9 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/SnippetExtension.kt @@ -0,0 +1,15 @@ +package com.itsaky.androidide.plugins.extensions + +import com.itsaky.androidide.plugins.IPlugin + +interface SnippetExtension : IPlugin { + fun getSnippetContributions(): List +} + +data class SnippetContribution( + val language: String, + val scope: String, + val prefix: String, + val description: String, + val body: List, +) \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeSnippetService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeSnippetService.kt new file mode 100644 index 0000000000..aaf0ba2edb --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeSnippetService.kt @@ -0,0 +1,5 @@ +package com.itsaky.androidide.plugins.services + +interface IdeSnippetService { + fun refreshSnippets(pluginId: String) +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index f903da11f3..a6ca57fa03 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -18,6 +18,10 @@ import com.itsaky.androidide.plugins.manager.services.IdeTooltipServiceImpl import com.itsaky.androidide.plugins.manager.services.IdeEditorTabServiceImpl import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.FileOpenExtension +import com.itsaky.androidide.plugins.extensions.SnippetExtension +import com.itsaky.androidide.plugins.manager.services.IdeSnippetServiceImpl +import com.itsaky.androidide.plugins.manager.snippets.PluginSnippetManager +import com.itsaky.androidide.plugins.services.IdeSnippetService import com.itsaky.androidide.plugins.extensions.FileTabMenuItem import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.manager.loaders.PluginManifest @@ -108,12 +112,17 @@ class PluginManager private constructor( private val pluginsDir = File(context.filesDir, "plugins") private val documentationManager = PluginDocumentationManager(context) private var templateReloadListener: (() -> Unit)? = null + private var snippetRefreshListener: ((String) -> Unit)? = null fun setTemplateReloadListener(listener: (() -> Unit)?) { this.templateReloadListener = listener PluginProjectManager.getInstance().setTemplateReloadListener(listener) } + fun setSnippetRefreshListener(listener: ((String) -> Unit)?) { + this.snippetRefreshListener = listener + } + // Helper methods for cleaner error handling private fun executeWithErrorHandling( operationDescription: String, @@ -406,10 +415,13 @@ class PluginManager private constructor( loadedPlugins[manifest.id] = loadedPlugin if (isEnabled) { try { + if (plugin is SnippetExtension) { + PluginSnippetManager.getInstance().registerPlugin(manifest.id, plugin) + } + plugin.activate() logger.info("Successfully loaded and activated plugin: ${manifest.name} (${manifest.id})") - // Verify and install/recreate documentation if plugin implements DocumentationExtension if (plugin is DocumentationExtension) { CoroutineScope(Dispatchers.IO).launch { try { @@ -471,6 +483,8 @@ class PluginManager private constructor( } PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) + PluginSnippetManager.getInstance().cleanupPlugin(pluginId) + snippetRefreshListener?.invoke(pluginId) val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) if (templateService is IdeTemplateServiceImpl) { @@ -942,6 +956,19 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeSnippetService::class.java, + pluginId, + "snippet" + ) { + IdeSnippetServiceImpl().apply { + setRefreshCallback { pid -> + snippetRefreshListener?.invoke(pid) + } + } + } + // Create PluginContext with resource context return PluginContextImpl( androidContext = resourceContext, // Use the resource context instead of app context @@ -1087,6 +1114,19 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeSnippetService::class.java, + pluginId, + "snippet" + ) { + IdeSnippetServiceImpl().apply { + setRefreshCallback { pid -> + snippetRefreshListener?.invoke(pid) + } + } + } + return PluginContextImpl( androidContext = context, services = pluginServiceRegistry, diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeSnippetServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeSnippetServiceImpl.kt new file mode 100644 index 0000000000..6d1ad658d0 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeSnippetServiceImpl.kt @@ -0,0 +1,20 @@ +package com.itsaky.androidide.plugins.manager.services + +import com.itsaky.androidide.plugins.services.IdeSnippetService +import org.slf4j.LoggerFactory + +class IdeSnippetServiceImpl : IdeSnippetService { + + private val log = LoggerFactory.getLogger(IdeSnippetServiceImpl::class.java) + + private var refreshCallback: ((String) -> Unit)? = null + + fun setRefreshCallback(callback: (String) -> Unit) { + this.refreshCallback = callback + } + + override fun refreshSnippets(pluginId: String) { + refreshCallback?.invoke(pluginId) + ?: log.warn("No refresh callback set for snippet service") + } +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/snippets/PluginSnippetManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/snippets/PluginSnippetManager.kt new file mode 100644 index 0000000000..94ded703d3 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/snippets/PluginSnippetManager.kt @@ -0,0 +1,73 @@ +package com.itsaky.androidide.plugins.manager.snippets + +import com.itsaky.androidide.plugins.extensions.SnippetContribution +import com.itsaky.androidide.plugins.extensions.SnippetExtension +import org.slf4j.LoggerFactory + +class PluginSnippetManager private constructor() { + + companion object { + private val log = LoggerFactory.getLogger(PluginSnippetManager::class.java) + + @Volatile + private var instance: PluginSnippetManager? = null + + fun getInstance(): PluginSnippetManager { + return instance ?: synchronized(this) { + instance ?: PluginSnippetManager().also { instance = it } + } + } + } + + private val extensions = mutableMapOf() + private val contributions = mutableMapOf>() + + fun registerPlugin(pluginId: String, extension: SnippetExtension) { + synchronized(contributions) { + extensions[pluginId] = extension + } + + val snippets = try { + extension.getSnippetContributions() + } catch (e: Exception) { + log.error("Failed to get snippet contributions from plugin: {}", pluginId, e) + emptyList() + } + + synchronized(contributions) { + contributions[pluginId] = snippets + } + log.info("Registered {} snippet contributions from plugin: {}", snippets.size, pluginId) + } + + fun refreshPlugin(pluginId: String): List { + val extension = synchronized(contributions) { extensions[pluginId] } ?: return emptyList() + val snippets = try { + extension.getSnippetContributions() + } catch (e: Exception) { + log.error("Failed to refresh snippet contributions from plugin: {}", pluginId, e) + return emptyList() + } + + synchronized(contributions) { + if (extensions[pluginId] == null) return emptyList() + contributions[pluginId] = snippets + } + log.info("Refreshed {} snippet contributions from plugin: {}", snippets.size, pluginId) + return snippets + } + + fun getAllSnippets(): Map> { + synchronized(contributions) { + return contributions.toMap() + } + } + + fun cleanupPlugin(pluginId: String) { + synchronized(contributions) { + extensions.remove(pluginId) + contributions.remove(pluginId) + } + log.debug("Cleaned up snippet contributions for plugin: {}", pluginId) + } +}