mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
feat: enhance PluginToggle and BuildNew components with support for implicit dependencies and dynamic labeling
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user