feat: enhance PluginToggle and BuildNew components with support for implicit dependencies and dynamic labeling

This commit is contained in:
Ben Allfree
2025-12-04 17:13:49 -08:00
parent 7e5777f1b4
commit 5b4a7a2e36
4 changed files with 380 additions and 41 deletions

View File

@@ -11,6 +11,8 @@ interface PluginToggleProps {
flashCount?: number
homepage?: string
version?: string
disabled?: boolean
enabledLabel?: string
}
export function PluginToggle({
@@ -22,6 +24,8 @@ export function PluginToggle({
flashCount = 0,
homepage,
version,
disabled = false,
enabledLabel = 'Add',
}: PluginToggleProps) {
return (
<div className="relative flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
@@ -68,8 +72,9 @@ export function PluginToggle({
<Switch
checked={isEnabled}
onCheckedChange={onToggle}
disabled={disabled}
labelLeft="Skip"
labelRight="Add"
labelRight={enabledLabel}
className={isEnabled ? 'bg-green-600' : 'bg-slate-600'}
/>
</div>

View File

@@ -44,3 +44,84 @@ export function humanizeStatus(status: string): string {
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
/**
* Resolves plugin dependencies recursively from registry.
* Returns all plugins that should be enabled (selected plugins + their dependencies).
* Filters out "meshtastic" as it's a firmware version requirement, not a plugin.
*/
export function getDependedPlugins(
selectedPlugins: string[],
registry: Record<string, { dependencies?: Record<string, string> }>
): string[] {
const result = new Set<string>()
const visited = new Set<string>()
function resolveDependencies(pluginId: string) {
// Prevent circular dependencies
if (visited.has(pluginId)) return
visited.add(pluginId)
const plugin = registry[pluginId]
if (!plugin || !plugin.dependencies) return
// Process each dependency
for (const [depId] of Object.entries(plugin.dependencies)) {
// Skip "meshtastic" - it's a firmware version requirement, not a plugin
if (depId === 'meshtastic') continue
// Only include dependencies that exist in the registry
if (depId in registry) {
result.add(depId)
// Recursively resolve transitive dependencies
resolveDependencies(depId)
}
}
}
// Start with selected plugins
for (const pluginId of selectedPlugins) {
if (pluginId in registry) {
result.add(pluginId)
resolveDependencies(pluginId)
}
}
return Array.from(result)
}
/**
* Gets only the implicit dependencies (dependencies that are not explicitly selected).
* Returns a set of plugin IDs that are dependencies but not in the explicitly selected list.
*/
export function getImplicitDependencies(
explicitlySelectedPlugins: string[],
registry: Record<string, { dependencies?: Record<string, string> }>
): Set<string> {
const allDependencies = getDependedPlugins(explicitlySelectedPlugins, registry)
const explicitSet = new Set(explicitlySelectedPlugins)
return new Set(allDependencies.filter((id) => !explicitSet.has(id)))
}
/**
* Checks if a plugin is required by any other explicitly selected plugin.
* Returns true if the plugin is a dependency (direct or transitive) of at least one explicitly selected plugin.
*/
export function isRequiredByOther(
pluginId: string,
explicitlySelectedPlugins: string[],
registry: Record<string, { dependencies?: Record<string, string> }>
): boolean {
// Check if any explicitly selected plugin depends on this plugin
for (const selectedId of explicitlySelectedPlugins) {
if (selectedId === pluginId) continue // Skip self
// Get all dependencies (including transitive) of this selected plugin
const allDeps = getDependedPlugins([selectedId], registry)
if (allDeps.includes(pluginId)) {
return true
}
}
return false
}

View File

@@ -6,6 +6,11 @@ import { toast } from 'sonner'
import { ModuleToggle } from '@/components/ModuleToggle'
import { PluginToggle } from '@/components/PluginToggle'
import { Button } from '@/components/ui/button'
import {
getDependedPlugins,
getImplicitDependencies,
isRequiredByOther,
} from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import modulesData from '../../convex/modules.json'
import registryData from '../../registry/registry.json'
@@ -164,12 +169,33 @@ export default function BuildNew() {
}
// Set plugin config (convert array to object format)
// Only add explicitly selected plugins, not implicit dependencies
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
const allPluginSlugs = config.pluginsEnabled.map((pluginId) => {
return pluginId.includes('@') ? pluginId.split('@')[0] : pluginId
})
// Determine which plugins are required by others (implicit dependencies)
const requiredByOthers = new Set<string>()
for (const pluginSlug of allPluginSlugs) {
if (
isRequiredByOther(
pluginSlug,
allPluginSlugs,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
) {
requiredByOthers.add(pluginSlug)
}
}
// Only add plugins that are NOT required by others (explicitly selected)
const pluginObj: Record<string, boolean> = {}
config.pluginsEnabled.forEach((pluginId) => {
// Extract slug from "slug@version" format if present
const slug = pluginId.includes('@') ? pluginId.split('@')[0] : pluginId
if (slug in registryData) {
allPluginSlugs.forEach((slug) => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
})
@@ -198,12 +224,85 @@ export default function BuildNew() {
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
// Get current explicit selections
const explicitPlugins = Object.keys(pluginConfig).filter(
(pluginId) => pluginConfig[pluginId] === true
)
// Check if this plugin is currently an implicit dependency
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
// Check if this plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
id,
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
// Don't allow toggling implicit dependencies at all
// (they should be disabled in the UI, but add this as a safeguard)
if (implicitDeps.has(id)) {
return // Can't toggle implicit dependencies
}
// Don't allow disabling if it's required by another explicitly selected plugin
if (!enabled && isRequired) {
return // Can't disable required plugins
}
setPluginConfig((prev) => {
const next = { ...prev }
if (enabled) {
// Enabling: add to explicit selection (even if it was implicit)
next[id] = true
} else {
// Disabling: remove from explicit selection
delete next[id]
// Recompute what plugins are still needed after removal
const remainingExplicit = Object.keys(next).filter(
(pluginId) => next[pluginId] === true
)
const allStillNeeded = getDependedPlugins(
remainingExplicit,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
// Remove any plugins from config that are no longer needed
// BUT preserve all plugins that are currently explicitly selected (in remainingExplicit)
// This ensures that plugins that were explicitly selected remain explicitly selected
// even if they temporarily became implicit and then un-implicit
for (const pluginId of Object.keys(next)) {
if (
next[pluginId] === true &&
!allStillNeeded.includes(pluginId) &&
!remainingExplicit.includes(pluginId)
) {
// This plugin is no longer needed and is not in the remaining explicit list
// Only remove if it's truly not needed and wasn't explicitly selected
// Note: If a plugin is in `next` with value `true`, it should be in `remainingExplicit`
// So this condition should rarely be true, but we keep it as a safety check
delete next[pluginId]
}
}
// Ensure all remaining explicitly selected plugins stay in config
// (they should already be there, but this ensures they remain even if they're not needed)
for (const pluginId of remainingExplicit) {
next[pluginId] = true
}
}
return next
})
@@ -217,7 +316,21 @@ export default function BuildNew() {
const enabledSlugs = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
)
const pluginsEnabled = enabledSlugs.map((slug) => {
// Double-check: filter out any implicit dependencies that might have snuck in
// This ensures we only send explicitly selected plugins to the backend
const implicitDeps = getImplicitDependencies(
enabledSlugs,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
const explicitOnlySlugs = enabledSlugs.filter(
(slug) => !implicitDeps.has(slug)
)
const pluginsEnabled = explicitOnlySlugs.map((slug) => {
const plugin = (registryData as Record<string, { version: string }>)[
slug
]
@@ -442,33 +555,77 @@ export default function BuildNew() {
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{Object.entries(registryData)
.sort(([, pluginA], [, pluginB]) => {
// Featured plugins first
const featuredA = pluginA.featured ?? false
const featuredB = pluginB.featured ?? false
if (featuredA !== featuredB) {
return featuredA ? -1 : 1
}
// Then alphabetical by name
return pluginA.name.localeCompare(pluginB.name)
})
.map(([slug, plugin]) => (
<PluginToggle
key={slug}
id={slug}
name={plugin.name}
description={plugin.description}
isEnabled={pluginConfig[slug] === true}
onToggle={(enabled) =>
handleTogglePlugin(slug, enabled)
{(() => {
// Get explicitly selected plugins (user-selected)
const explicitPlugins = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
)
// Compute implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
return Object.entries(registryData)
.sort(([, pluginA], [, pluginB]) => {
// Featured plugins first
const featuredA = pluginA.featured ?? false
const featuredB = pluginB.featured ?? false
if (featuredA !== featuredB) {
return featuredA ? -1 : 1
}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
version={plugin.version}
/>
))}
// Then alphabetical by name
return pluginA.name.localeCompare(pluginB.name)
})
.map(([slug, plugin]) => {
// Check if plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
slug,
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
// Plugin is implicit if it's either:
// 1. Not explicitly selected but is a dependency, OR
// 2. Explicitly selected but required by another explicitly selected plugin
const isImplicit =
implicitDeps.has(slug) ||
(explicitPlugins.includes(slug) && isRequired)
return (
<PluginToggle
key={slug}
id={slug}
name={plugin.name}
description={plugin.description}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={(enabled) =>
handleTogglePlugin(slug, enabled)
}
disabled={isImplicit}
enabledLabel={isImplicit ? 'Required' : 'Add'}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
version={plugin.version}
/>
)
})
})()}
</div>
</div>
)}

View File

@@ -12,7 +12,11 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { BuildDownloadButton } from '@/components/BuildDownloadButton'
import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
import {
getDependedPlugins,
getImplicitDependencies,
humanizeStatus,
} from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import { ArtifactType } from '../../convex/builds'
import modulesData from '../../convex/modules.json'
@@ -231,19 +235,76 @@ export default function BuildProgress() {
(module) => build.config.modulesExcluded[module.id] === true
)
// Get enabled plugins
const enabledPlugins = (build.config.pluginsEnabled || []).map((pluginId) => {
// Get explicitly selected plugins from stored config
// The stored config only contains explicitly selected plugins (not resolved dependencies)
const explicitPluginSlugs = (build.config.pluginsEnabled || []).map(
(pluginId) => {
// Extract slug from "slug@version" format if present
return pluginId.includes('@') ? pluginId.split('@')[0] : pluginId
}
)
// Resolve dependencies to get all plugins that should be enabled
const allResolvedPlugins = getDependedPlugins(
explicitPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Get implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Separate explicit and implicit plugins
const explicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
const implicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
// Process explicitly selected plugins
;(build.config.pluginsEnabled || []).forEach((pluginId) => {
// Extract slug from "slug@version" format if present
const slug = pluginId.includes('@') ? pluginId.split('@')[0] : pluginId
const pluginData = (registryData as Record<string, { name: string; description: string; version: string }>)[slug]
return {
const pluginData = (registryData as Record<
string,
{ name: string; description: string; version: string }
>)[slug]
const pluginInfo = {
id: slug,
name: pluginData?.name || slug,
description: pluginData?.description || '',
version: pluginId.includes('@') ? pluginId.split('@')[1] : pluginData?.version || '',
version: pluginId.includes('@')
? pluginId.split('@')[1]
: pluginData?.version || '',
}
explicitPlugins.push(pluginInfo)
})
// Process implicit dependencies (resolved but not in stored config)
for (const slug of implicitDeps) {
const pluginData = (registryData as Record<
string,
{ name: string; description: string; version: string }
>)[slug]
if (pluginData) {
implicitPlugins.push({
id: slug,
name: pluginData.name || slug,
description: pluginData.description || '',
version: pluginData.version || '',
})
}
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-6">
@@ -420,7 +481,9 @@ export default function BuildProgress() {
)}
{/* Build Configuration Summary */}
{(excludedModules.length > 0 || enabledPlugins.length > 0) && (
{(excludedModules.length > 0 ||
explicitPlugins.length > 0 ||
implicitPlugins.length > 0) && (
<div className="space-y-6 border-t border-slate-800 pt-6">
{/* Excluded Modules */}
{excludedModules.length > 0 && (
@@ -447,12 +510,45 @@ export default function BuildProgress() {
)}
{/* Enabled Plugins */}
{enabledPlugins.length > 0 && (
{explicitPlugins.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Enabled Plugins</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{enabledPlugins.map((plugin) => (
{explicitPlugins.map((plugin) => (
<div
key={plugin.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
>
<div className="flex items-center gap-2 mb-1">
<h4 className="text-base font-medium">
{plugin.name}
</h4>
{plugin.version && (
<span className="text-xs text-slate-500">
v{plugin.version}
</span>
)}
</div>
{plugin.description && (
<p className="text-slate-400 text-sm">
{plugin.description}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* Required Plugins (Implicit Dependencies) */}
{implicitPlugins.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Required Plugins</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{implicitPlugins.map((plugin) => (
<div
key={plugin.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"