diff --git a/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt b/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt new file mode 100644 index 0000000..e47c813 --- /dev/null +++ b/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt @@ -0,0 +1,113 @@ +package com.gocodalone.workflow.ide.marketplace + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.content.ContentFactory +import com.intellij.ui.table.JBTable +import com.intellij.util.io.HttpRequests +import java.awt.BorderLayout +import javax.swing.* +import javax.swing.table.DefaultTableModel + +private val LOG = Logger.getInstance(MarketplaceToolWindowFactory::class.java) + +private const val REGISTRY_URL = "https://gocodealone.github.io/workflow-registry/v1/index.json" + +class MarketplaceToolWindowFactory : ToolWindowFactory { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val panel = MarketplacePanel(project) + val content = ContentFactory.getInstance().createContent(panel, "Plugins", false) + toolWindow.contentManager.addContent(content) + } +} + +/** Read-only table model — prevents accidental in-place cell editing. */ +private class ReadOnlyTableModel(columns: Array) : + DefaultTableModel(columns, 0) { + override fun isCellEditable(row: Int, column: Int): Boolean = false +} + +class MarketplacePanel(private val project: Project) : JPanel(BorderLayout()) { + private val tableModel = ReadOnlyTableModel(arrayOf("Name", "Version", "Tier", "Description")) + private val table = JBTable(tableModel) + private val statusLabel = JLabel("Loading plugins\u2026", SwingConstants.CENTER) + + init { + add(JScrollPane(table), BorderLayout.CENTER) + add(statusLabel, BorderLayout.SOUTH) + loadPlugins() + } + + fun loadPlugins() { + SwingUtilities.invokeLater { + tableModel.rowCount = 0 + statusLabel.text = "Loading plugins\u2026" + } + ApplicationManager.getApplication().executeOnPooledThread { + try { + val json = HttpRequests.request(REGISTRY_URL).readString() + val allPlugins = parseRegistryIndex(json) + val publicPlugins = filterPublicPlugins(allPlugins) + SwingUtilities.invokeLater { + tableModel.rowCount = 0 + for (p in publicPlugins) { + tableModel.addRow(arrayOf( + p["name"]?.toString() ?: "", + p["version"]?.toString() ?: "", + p["tier"]?.toString() ?: "", + p["description"]?.toString() ?: "" + )) + } + val shown = publicPlugins.size + statusLabel.text = if (shown == 0) "No public plugins found." else "$shown plugin(s) available." + } + } catch (e: HttpRequests.HttpStatusException) { + LOG.warn("Workflow plugin registry returned HTTP ${e.statusCode}: $REGISTRY_URL") + SwingUtilities.invokeLater { + statusLabel.text = "Could not load plugins (HTTP ${e.statusCode})." + } + } catch (e: JsonSyntaxException) { + LOG.warn("Workflow plugin registry returned malformed JSON", e) + SwingUtilities.invokeLater { + statusLabel.text = "Could not load plugins (invalid response)." + } + } catch (e: Exception) { + LOG.warn("Workflow plugin registry unavailable", e) + SwingUtilities.invokeLater { + statusLabel.text = "Could not load plugins (network error)." + } + } + } + } +} + +/** + * Parses the registry index JSON into a list of plugin maps. + * Returns an empty list if the JSON is null, empty, or malformed. + */ +internal fun parseRegistryIndex(json: String): List> { + if (json.isBlank()) return emptyList() + val type = object : TypeToken>>() {}.type + return Gson().fromJson>>(json, type) ?: emptyList() +} + +/** + * Filters out private plugins. Handles boolean and string "true"/"false" + * for the `private` field, defaulting to public when the field is absent. + */ +internal fun filterPublicPlugins(plugins: List>): List> { + return plugins.filter { p -> + val isPrivate = when (val raw = p["private"]) { + is Boolean -> raw + is String -> raw.equals("true", ignoreCase = true) + else -> false + } + !isPrivate + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 15649bb..2e1c093 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -62,6 +62,13 @@ icon="AllIcons.FileTypes.Diagram" factoryClass="com.gocodalone.workflow.ide.editor.WorkflowVisualEditorToolWindowFactory"/> + + + diff --git a/src/test/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceParsingTest.kt b/src/test/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceParsingTest.kt new file mode 100644 index 0000000..bd6c721 --- /dev/null +++ b/src/test/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceParsingTest.kt @@ -0,0 +1,76 @@ +package com.gocodalone.workflow.ide.marketplace + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MarketplaceParsingTest : BasePlatformTestCase() { + + fun testParseRegistryIndexValidJson() { + val json = """[{"name":"plugin-a","version":"1.0","tier":"core"},{"name":"plugin-b","version":"0.1","tier":"community"}]""" + val result = parseRegistryIndex(json) + assertEquals(2, result.size) + assertEquals("plugin-a", result[0]["name"]) + assertEquals("plugin-b", result[1]["name"]) + } + + fun testParseRegistryIndexEmptyArray() { + val result = parseRegistryIndex("[]") + assertTrue(result.isEmpty()) + } + + fun testParseRegistryIndexNullJson() { + val result = parseRegistryIndex("null") + assertTrue(result.isEmpty()) + } + + fun testParseRegistryIndexEmptyString() { + val result = parseRegistryIndex("") + assertTrue(result.isEmpty()) + } + + fun testParseRegistryIndexBlankString() { + val result = parseRegistryIndex(" ") + assertTrue(result.isEmpty()) + } + + fun testFilterPublicPluginsExcludesPrivateBoolean() { + val plugins = listOf( + mapOf("name" to "public-plugin", "private" to false), + mapOf("name" to "private-plugin", "private" to true), + ) + val result = filterPublicPlugins(plugins) + assertEquals(1, result.size) + assertEquals("public-plugin", result[0]["name"]) + } + + fun testFilterPublicPluginsExcludesPrivateString() { + val plugins = listOf( + mapOf("name" to "public-plugin"), + mapOf("name" to "private-plugin", "private" to "true"), + mapOf("name" to "also-private", "private" to "TRUE"), + ) + val result = filterPublicPlugins(plugins) + assertEquals(1, result.size) + assertEquals("public-plugin", result[0]["name"]) + } + + fun testFilterPublicPluginsIncludesWhenFieldMissing() { + val plugins = listOf( + mapOf("name" to "no-private-field"), + ) + val result = filterPublicPlugins(plugins) + assertEquals(1, result.size) + } + + fun testFilterPublicPluginsEmptyList() { + val result = filterPublicPlugins(emptyList()) + assertTrue(result.isEmpty()) + } + + fun testParseRegistryIndexMissingFields() { + val json = """[{"name":"minimal"}]""" + val result = parseRegistryIndex(json) + assertEquals(1, result.size) + assertEquals("minimal", result[0]["name"]) + assertNull(result[0]["version"]) + } +}