Skip to content
Merged
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,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<String>) :
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)."
}
}
Comment on lines +80 to +85
}
}
}

/**
* 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<Map<String, Any>> {
if (json.isBlank()) return emptyList()
val type = object : TypeToken<List<Map<String, Any>>>() {}.type
return Gson().fromJson<List<Map<String, Any>>>(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<Map<String, Any>>): List<Map<String, Any>> {
return plugins.filter { p ->
val isPrivate = when (val raw = p["private"]) {
is Boolean -> raw
is String -> raw.equals("true", ignoreCase = true)
else -> false
}
!isPrivate
}
}
7 changes: 7 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@
icon="AllIcons.FileTypes.Diagram"
factoryClass="com.gocodalone.workflow.ide.editor.WorkflowVisualEditorToolWindowFactory"/>

<!-- Plugin marketplace tool window -->
<toolWindow id="Workflow Marketplace"
anchor="right"
doNotActivateOnStart="true"
icon="AllIcons.Actions.Download"
factoryClass="com.gocodalone.workflow.ide.marketplace.MarketplaceToolWindowFactory"/>

<!-- Editor notification banner for detected workflow files -->
<editorNotificationProvider
implementation="com.gocodalone.workflow.ide.editor.WorkflowDetectionNotificationProvider"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any>("name" to "public-plugin", "private" to false),
mapOf<String, Any>("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<String, Any>("name" to "public-plugin"),
mapOf<String, Any>("name" to "private-plugin", "private" to "true"),
mapOf<String, Any>("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<String, Any>("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"])
}
}
Loading