From fdea72aed233b944ceb2202d790fd8ef6d17ebc8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:19:23 -0400 Subject: [PATCH 1/3] feat: add plugin marketplace tool window Adds a Workflow Marketplace tool window panel (anchored right) that fetches the public plugin index from the workflow-registry GitHub Pages endpoint and displays name, version, tier, and description for all non-private plugins in a scrollable JBTable. Uses Gson and HttpRequests already present in the project. Co-Authored-By: Claude Sonnet 4.6 --- .../ide/marketplace/MarketplaceToolWindow.kt | 59 +++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 6 ++ 2 files changed, 65 insertions(+) create mode 100644 src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt 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..2460a16 --- /dev/null +++ b/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt @@ -0,0 +1,59 @@ +package com.gocodalone.workflow.ide.marketplace + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.intellij.openapi.application.ApplicationManager +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 javax.swing.* +import javax.swing.table.DefaultTableModel + +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) + } +} + +class MarketplacePanel(private val project: Project) : JPanel() { + private val tableModel = DefaultTableModel(arrayOf("Name", "Version", "Tier", "Description"), 0) + private val table = JBTable(tableModel) + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(JScrollPane(table)) + loadPlugins() + } + + private fun loadPlugins() { + ApplicationManager.getApplication().executeOnPooledThread { + try { + val json = HttpRequests.request("https://gocodealone.github.io/workflow-registry/v1/index.json") + .readString() + val type = object : TypeToken>>() {}.type + val plugins: List> = Gson().fromJson(json, type) + SwingUtilities.invokeLater { + tableModel.rowCount = 0 + for (p in plugins) { + val isPrivate = p["private"] as? Boolean ?: false + if (!isPrivate) { + tableModel.addRow(arrayOf( + p["name"] ?: "", + p["version"] ?: "", + p["tier"] ?: "", + p["description"] ?: "" + )) + } + } + } + } catch (e: Exception) { + // Silently fail — marketplace is optional + } + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 15649bb..5831c13 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -62,6 +62,12 @@ icon="AllIcons.FileTypes.Diagram" factoryClass="com.gocodalone.workflow.ide.editor.WorkflowVisualEditorToolWindowFactory"/> + + + From 68e0d904388d358e32bc84dc7fb7ab96a1559e10 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:44:09 -0400 Subject: [PATCH 2/3] fix(marketplace): error handling, type safety, and eager-activation bug - Add structured error logging via Logger for HTTP errors, malformed JSON, and general network failures instead of silently swallowing all exceptions - Add status label so users see loading/error/count feedback - Guard `private` field against non-Boolean types (e.g. string "true") since Gson can vary when the registry schema evolves - Use ReadOnlyTableModel to prevent accidental in-place cell editing - Use toString() on all map values so Double/Long from Gson don't appear with type-cast noise in the table - Add doNotActivateOnStart="true" in plugin.xml so the marketplace tool window doesn't fire a network request on every project open Co-Authored-By: Claude Sonnet 4.6 --- .../ide/marketplace/MarketplaceToolWindow.kt | 65 +++++++++++++++---- src/main/resources/META-INF/plugin.xml | 1 + 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt b/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt index 2460a16..c1a0360 100644 --- a/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt +++ b/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt @@ -1,17 +1,24 @@ 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) @@ -20,39 +27,71 @@ class MarketplaceToolWindowFactory : ToolWindowFactory { } } -class MarketplacePanel(private val project: Project) : JPanel() { - private val tableModel = DefaultTableModel(arrayOf("Name", "Version", "Tier", "Description"), 0) +/** 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 { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(JScrollPane(table)) + add(JScrollPane(table), BorderLayout.CENTER) + add(statusLabel, BorderLayout.SOUTH) loadPlugins() } - private fun loadPlugins() { + fun loadPlugins() { + SwingUtilities.invokeLater { + tableModel.rowCount = 0 + statusLabel.text = "Loading plugins\u2026" + } ApplicationManager.getApplication().executeOnPooledThread { try { - val json = HttpRequests.request("https://gocodealone.github.io/workflow-registry/v1/index.json") - .readString() + val json = HttpRequests.request(REGISTRY_URL).readString() val type = object : TypeToken>>() {}.type val plugins: List> = Gson().fromJson(json, type) SwingUtilities.invokeLater { tableModel.rowCount = 0 + var shown = 0 for (p in plugins) { - val isPrivate = p["private"] as? Boolean ?: false + // Gson deserialises JSON booleans as Boolean, but guard against + // string "true"/"false" in case the registry schema evolves. + val isPrivate = when (val raw = p["private"]) { + is Boolean -> raw + is String -> raw.equals("true", ignoreCase = true) + else -> false + } if (!isPrivate) { tableModel.addRow(arrayOf( - p["name"] ?: "", - p["version"] ?: "", - p["tier"] ?: "", - p["description"] ?: "" + p["name"]?.toString() ?: "", + p["version"]?.toString() ?: "", + p["tier"]?.toString() ?: "", + p["description"]?.toString() ?: "" )) + shown++ } } + 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) { - // Silently fail — marketplace is optional + LOG.warn("Workflow plugin registry unavailable", e) + SwingUtilities.invokeLater { + statusLabel.text = "Could not load plugins (network error)." + } } } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 5831c13..2e1c093 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -65,6 +65,7 @@ From 2eb7e44d27099b155c75eb584dfa60c8cb69bde4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 20:23:05 -0400 Subject: [PATCH 3/3] fix: address all Copilot review comments on PR #1 - Handle null return from Gson.fromJson() with ?: emptyList() - Extract parseRegistryIndex() and filterPublicPlugins() as internal functions for testability - Add MarketplaceParsingTest with 9 test cases covering: valid/empty/null JSON, blank strings, private field as boolean/string/missing, empty list, and missing fields - Read-only table model and exception logging were already in place Co-Authored-By: Claude Opus 4.6 --- .../ide/marketplace/MarketplaceToolWindow.kt | 55 +++++++++----- .../ide/marketplace/MarketplaceParsingTest.kt | 76 +++++++++++++++++++ 2 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 src/test/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceParsingTest.kt diff --git a/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt b/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt index c1a0360..e47c813 100644 --- a/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt +++ b/src/main/kotlin/com/gocodalone/workflow/ide/marketplace/MarketplaceToolWindow.kt @@ -52,29 +52,19 @@ class MarketplacePanel(private val project: Project) : JPanel(BorderLayout()) { ApplicationManager.getApplication().executeOnPooledThread { try { val json = HttpRequests.request(REGISTRY_URL).readString() - val type = object : TypeToken>>() {}.type - val plugins: List> = Gson().fromJson(json, type) + val allPlugins = parseRegistryIndex(json) + val publicPlugins = filterPublicPlugins(allPlugins) SwingUtilities.invokeLater { tableModel.rowCount = 0 - var shown = 0 - for (p in plugins) { - // Gson deserialises JSON booleans as Boolean, but guard against - // string "true"/"false" in case the registry schema evolves. - val isPrivate = when (val raw = p["private"]) { - is Boolean -> raw - is String -> raw.equals("true", ignoreCase = true) - else -> false - } - if (!isPrivate) { - tableModel.addRow(arrayOf( - p["name"]?.toString() ?: "", - p["version"]?.toString() ?: "", - p["tier"]?.toString() ?: "", - p["description"]?.toString() ?: "" - )) - shown++ - } + 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) { @@ -96,3 +86,28 @@ class MarketplacePanel(private val project: Project) : JPanel(BorderLayout()) { } } } + +/** + * 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/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"]) + } +}