From f25f230ce9daeb91be78801286f43bb1439f4795 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 8 Apr 2026 14:22:23 +0100 Subject: [PATCH 1/3] ADFA-3546: Add code snippet plugin support Adds a snippets plugin that lets users create, edit, and delete custom code completion snippets through a Material 3 editor tab. Snippets are stored in .codeonthego/snippets.json per project and bootstrapped with useful Java defaults on first use. Backs into a new SnippetRegistry and IdeSnippetService for live snippet registration across built-in, user, and plugin sources. --- .../activities/editor/BaseEditorActivity.kt | 8 ++ .../androidide/handlers/SnippetHandler.kt | 82 +++++++++++++++++ .../itsaky/androidide/utils/Environment.java | 2 + .../androidide/lsp/snippets/SnippetParser.kt | 29 ++++-- .../lsp/snippets/SnippetRegistry.kt | 92 +++++++++++++++++++ .../lsp/snippets/UserSnippetLoader.kt | 37 ++++++++ .../snippet/JavaSnippetRepository.kt | 15 ++- .../providers/snippet/XmlSnippetRepository.kt | 15 ++- .../plugins/extensions/SnippetExtension.kt | 15 +++ .../plugins/services/IdeSnippetService.kt | 5 + .../plugins/manager/core/PluginManager.kt | 41 ++++++++- .../manager/services/IdeSnippetServiceImpl.kt | 20 ++++ .../manager/snippets/PluginSnippetManager.kt | 69 ++++++++++++++ 13 files changed, 402 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt create mode 100644 lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt create mode 100644 lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/SnippetExtension.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeSnippetService.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeSnippetServiceImpl.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/snippets/PluginSnippetManager.kt 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..d34b9ee559 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 @@ -614,6 +617,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..00b3aa8843 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt @@ -0,0 +1,82 @@ +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.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) -> + contributions.forEach { contribution -> + val snippet = DefaultSnippet( + contribution.prefix, + contribution.description, + contribution.body.toTypedArray(), + ) + SnippetRegistry.registerPluginSnippets( + pluginId, + contribution.language, + contribution.scope, + listOf(snippet), + ) + } + } + 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) + contributions.forEach { contribution -> + val snippet = DefaultSnippet( + contribution.prefix, + contribution.description, + contribution.body.toTypedArray(), + ) + SnippetRegistry.registerPluginSnippets( + pluginId, + contribution.language, + contribution.scope, + listOf(snippet), + ) + } + } + + fun removePluginSnippets(pluginId: String) { + SnippetRegistry.unregisterPluginSnippets(pluginId) + } + + private fun loadUserSnippetsForLanguage( + language: String, + scopes: Array, + ) { + 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..80a21525d3 --- /dev/null +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt @@ -0,0 +1,92 @@ +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() } + .getOrPut(scope) { mutableListOf() } + .addAll(snippets) + } + } + + fun registerUserSnippets(language: String, scope: String, snippets: List) { + lock.write { + user.getOrPut(language) { mutableMapOf() } + .getOrPut(scope) { mutableListOf() } + .addAll(snippets) + } + } + + fun registerPluginSnippets( + pluginId: String, + language: String, + scope: String, + snippets: List, + ) { + lock.write { + plugin.getOrPut(pluginId) { mutableMapOf() } + .getOrPut(language) { mutableMapOf() } + .getOrPut(scope) { mutableListOf() } + .addAll(snippets) + } + } + + 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.toMutableList() + } + } + } + + 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..4cb9545724 --- /dev/null +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt @@ -0,0 +1,37 @@ +package com.itsaky.androidide.lsp.snippets + +import com.itsaky.androidide.utils.Environment +import org.slf4j.LoggerFactory +import java.io.File + +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: Exception) { + 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) + } +} \ No newline at end of file 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..f6688a109a 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, @@ -409,7 +418,6 @@ class PluginManager private constructor( 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 { @@ -424,6 +432,10 @@ class PluginManager private constructor( } } } + + if (plugin is SnippetExtension) { + PluginSnippetManager.getInstance().registerPlugin(manifest.id, plugin) + } } catch (e: Exception) { logger.error("Failed to activate plugin: ${manifest.id}", e) loadedPlugin.isEnabled = false @@ -471,6 +483,7 @@ class PluginManager private constructor( } PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) + PluginSnippetManager.getInstance().cleanupPlugin(pluginId) val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) if (templateService is IdeTemplateServiceImpl) { @@ -942,6 +955,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 +1113,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..472caf0377 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/snippets/PluginSnippetManager.kt @@ -0,0 +1,69 @@ +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) { + val snippets = try { + extension.getSnippetContributions() + } catch (e: Exception) { + log.error("Failed to get snippet contributions from plugin: {}", pluginId, e) + return + } + + synchronized(contributions) { + extensions[pluginId] = extension + 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) { + 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) + } +} From be32977d40816c90bde892db2b925b68bf70023b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 8 Apr 2026 14:34:05 +0100 Subject: [PATCH 2/3] ADFA-3546: Add code snippet plugin support Adds a snippets plugin that lets users create, edit, and delete custom code completion snippets through a Material 3 editor tab. Snippets are stored in .codeonthego/snippets.json per project and bootstrapped with useful Java defaults on first use. Backs into a new SnippetRegistry and IdeSnippetService for live snippet registration across built-in, user, and plugin sources. --- .../androidide/handlers/SnippetHandler.kt | 36 ++++++------------- .../lsp/snippets/SnippetRegistry.kt | 12 ++----- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt index 00b3aa8843..c08b70879f 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt @@ -4,6 +4,7 @@ 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 @@ -20,22 +21,9 @@ object SnippetHandler { fun loadPluginSnippets() { - val allSnippets = PluginSnippetManager.getInstance().getAllSnippets() allSnippets.forEach { (pluginId, contributions) -> - contributions.forEach { contribution -> - val snippet = DefaultSnippet( - contribution.prefix, - contribution.description, - contribution.body.toTypedArray(), - ) - SnippetRegistry.registerPluginSnippets( - pluginId, - contribution.language, - contribution.scope, - listOf(snippet), - ) - } + registerContributions(pluginId, contributions) } if (allSnippets.isNotEmpty()) { log.info("Loaded plugin snippets from {} plugins", allSnippets.size) @@ -45,18 +33,14 @@ object SnippetHandler { fun refreshPluginSnippets(pluginId: String) { SnippetRegistry.unregisterPluginSnippets(pluginId) val contributions = PluginSnippetManager.getInstance().refreshPlugin(pluginId) - contributions.forEach { contribution -> - val snippet = DefaultSnippet( - contribution.prefix, - contribution.description, - contribution.body.toTypedArray(), - ) - SnippetRegistry.registerPluginSnippets( - pluginId, - contribution.language, - contribution.scope, - listOf(snippet), - ) + 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) } } 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 index 80a21525d3..58102c1da2 100644 --- 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 @@ -14,17 +14,13 @@ object SnippetRegistry { fun registerBuiltIn(language: String, scope: String, snippets: List) { lock.write { - builtIn.getOrPut(language) { mutableMapOf() } - .getOrPut(scope) { mutableListOf() } - .addAll(snippets) + builtIn.getOrPut(language) { mutableMapOf() }[scope] = snippets.toMutableList() } } fun registerUserSnippets(language: String, scope: String, snippets: List) { lock.write { - user.getOrPut(language) { mutableMapOf() } - .getOrPut(scope) { mutableListOf() } - .addAll(snippets) + user.getOrPut(language) { mutableMapOf() }[scope] = snippets.toMutableList() } } @@ -36,9 +32,7 @@ object SnippetRegistry { ) { lock.write { plugin.getOrPut(pluginId) { mutableMapOf() } - .getOrPut(language) { mutableMapOf() } - .getOrPut(scope) { mutableListOf() } - .addAll(snippets) + .getOrPut(language) { mutableMapOf() }[scope] = snippets.toMutableList() } } From ee1df91f53662a440993253451eb5f1c990544d5 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 8 Apr 2026 14:56:05 +0100 Subject: [PATCH 3/3] Address coderabit comments --- .../androidide/activities/editor/BaseEditorActivity.kt | 2 ++ .../com/itsaky/androidide/handlers/SnippetHandler.kt | 1 + .../itsaky/androidide/lsp/snippets/SnippetRegistry.kt | 2 +- .../itsaky/androidide/lsp/snippets/UserSnippetLoader.kt | 9 +++++++-- .../androidide/plugins/manager/core/PluginManager.kt | 9 +++++---- .../plugins/manager/snippets/PluginSnippetManager.kt | 8 ++++++-- 6 files changed, 22 insertions(+), 9 deletions(-) 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 d34b9ee559..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 @@ -429,6 +429,8 @@ abstract class BaseEditorActivity : protected open fun preDestroy() { BuildOutputProvider.clearBottomSheet() + IDEApplication.getPluginManager()?.setSnippetRefreshListener(null) + Shizuku.removeBinderReceivedListener(shizukuBinderReceivedListener) if (isAtLeastR()) wadbConnectionViewModel.stop(this) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt index c08b70879f..654d55aebe 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt @@ -52,6 +52,7 @@ object SnippetHandler { language: String, scopes: Array, ) { + SnippetRegistry.clearUserSnippets(language) val userSnippets = UserSnippetLoader.loadUserSnippets(language, scopes) userSnippets.forEach { (scope, snippets) -> if (snippets.isNotEmpty()) { 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 index 58102c1da2..f8e3721c39 100644 --- 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 @@ -71,7 +71,7 @@ object SnippetRegistry { val parsed = SnippetParser.parse(language, scopes) lock.write { parsed.forEach { (scope, snippets) -> - builtIn.getOrPut(language) { mutableMapOf() }[scope.filename] = snippets.toMutableList() + builtIn.getOrPut(language) { mutableMapOf() }[scope.filename] = snippets as MutableList } } } 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 index 4cb9545724..e94e54c640 100644 --- 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 @@ -1,8 +1,10 @@ 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 { @@ -20,7 +22,10 @@ object UserSnippetLoader { val snippets = if (file.isFile) { try { SnippetParser.parseFromReader(file.reader()) - } catch (e: Exception) { + } 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() } @@ -34,4 +39,4 @@ object UserSnippetLoader { fun getUserSnippetsDir(language: String): File { return File(Environment.SNIPPETS_DIR, language) } -} \ No newline at end of file +} 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 f6688a109a..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 @@ -415,6 +415,10 @@ 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})") @@ -432,10 +436,6 @@ class PluginManager private constructor( } } } - - if (plugin is SnippetExtension) { - PluginSnippetManager.getInstance().registerPlugin(manifest.id, plugin) - } } catch (e: Exception) { logger.error("Failed to activate plugin: ${manifest.id}", e) loadedPlugin.isEnabled = false @@ -484,6 +484,7 @@ 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) { 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 index 472caf0377..94ded703d3 100644 --- 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 @@ -23,15 +23,18 @@ class PluginSnippetManager private constructor() { 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) - return + emptyList() } synchronized(contributions) { - extensions[pluginId] = extension contributions[pluginId] = snippets } log.info("Registered {} snippet contributions from plugin: {}", snippets.size, pluginId) @@ -47,6 +50,7 @@ class PluginSnippetManager private constructor() { } synchronized(contributions) { + if (extensions[pluginId] == null) return emptyList() contributions[pluginId] = snippets } log.info("Refreshed {} snippet contributions from plugin: {}", snippets.size, pluginId)