feat: add global module manager

This commit is contained in:
reaper
2026-08-14 12:25:45 -05:00
parent f6c2bbfad6
commit ccc897a931
3 changed files with 57 additions and 14 deletions
+7 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Anchor, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react' import { Anchor, Boxes, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
import { ProjectDetail } from './components/projects/ProjectDetail' import { ProjectDetail } from './components/projects/ProjectDetail'
import { ProjectList } from './components/projects/ProjectList' import { ProjectList } from './components/projects/ProjectList'
import { TerminalPanel } from './components/terminal/TerminalPanel' import { TerminalPanel } from './components/terminal/TerminalPanel'
@@ -7,6 +7,7 @@ import { StatusBar } from './components/layout/StatusBar'
import { Toaster } from './components/ui/Toaster' import { Toaster } from './components/ui/Toaster'
import { CreateProjectModal } from './components/create/CreateProjectModal' import { CreateProjectModal } from './components/create/CreateProjectModal'
import { SettingsModal } from './components/settings/SettingsModal' import { SettingsModal } from './components/settings/SettingsModal'
import { GlobalModuleManager } from './components/modules/GlobalModuleManager'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { useTerminalEvents } from './hooks/useTerminalEvents' import { useTerminalEvents } from './hooks/useTerminalEvents'
import { useAppliedTheme } from './hooks/useAppliedTheme' import { useAppliedTheme } from './hooks/useAppliedTheme'
@@ -17,6 +18,7 @@ function App(): React.JSX.Element {
const selectedProjectName = useAppStore((s) => s.selectedProjectName) const selectedProjectName = useAppStore((s) => s.selectedProjectName)
const [isCreateOpen, setIsCreateOpen] = useState(false) const [isCreateOpen, setIsCreateOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [isModulesOpen, setIsModulesOpen] = useState(false)
useTerminalEvents() useTerminalEvents()
useAppliedTheme() useAppliedTheme()
useKeyboardShortcuts({ useKeyboardShortcuts({
@@ -64,6 +66,9 @@ function App(): React.JSX.Element {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ProjectList /> <ProjectList />
</div> </div>
<div className="border-t border-neutral-200/70 p-2.5 dark:border-white/10">
<button type="button" onClick={() => setIsModulesOpen(true)} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-neutral-600 transition hover:bg-cyan-50 hover:text-cyan-700 dark:text-neutral-300 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"><Boxes size={16}/>Modules</button>
</div>
</aside> </aside>
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
{selectedProjectName ? ( {selectedProjectName ? (
@@ -126,6 +131,7 @@ function App(): React.JSX.Element {
<Toaster /> <Toaster />
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />} {isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />} {isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
{isModulesOpen && <GlobalModuleManager onClose={() => setIsModulesOpen(false)} />}
</div> </div>
) )
} }
@@ -12,7 +12,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useCreateProject } from '../../hooks/useCreateProject' import { useCreateProject } from '../../hooks/useCreateProject'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
import { useAvailableModules, useInstallAvailableModule, useInstallModulePackage, useModuleRegistry } from '../../hooks/useModules' import { useModuleRegistry } from '../../hooks/useModules'
import { GenericSetup } from './types/GenericSetup' import { GenericSetup } from './types/GenericSetup'
import { ExternalModuleSetup } from './types/ExternalModuleSetup' import { ExternalModuleSetup } from './types/ExternalModuleSetup'
import type { TypeSetupHandle } from './types/shared' import type { TypeSetupHandle } from './types/shared'
@@ -52,9 +52,6 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const selectProject = useAppStore((s) => s.selectProject) const selectProject = useAppStore((s) => s.selectProject)
const setupRef = useRef<TypeSetupHandle>(null) const setupRef = useRef<TypeSetupHandle>(null)
const { data: moduleRegistry = [] } = useModuleRegistry() const { data: moduleRegistry = [] } = useModuleRegistry()
const installPackage = useInstallModulePackage()
const installAvailable = useInstallAvailableModule()
const { data: availableModules = [], isLoading: availableLoading } = useAvailableModules()
const applicationModules = moduleRegistry.filter((module) => module.category === 'application') const applicationModules = moduleRegistry.filter((module) => module.category === 'application')
const selectedModule = applicationModules.find((module) => module.id === projectType) const selectedModule = applicationModules.find((module) => module.id === projectType)
const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project' const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project'
@@ -264,15 +261,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
</button> </button>
) )
})} })}
{applicationModules.length === 0 && <div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100"> {applicationModules.length === 0 && <div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100"><p className="font-semibold">No application modules installed</p><p className="mt-1">Close this window and use <strong>Modules</strong> at the bottom of the sidebar to install one.</p></div>}
<p className="font-semibold">Available application modules</p>
<p className="mt-1">Choose a module to add to Aurora.</p>
<div className="mt-3 grid gap-2">
{availableModules.filter((item) => item.manifest.category === 'application').map((item) => <div key={item.manifest.id} className="flex items-center justify-between gap-3 rounded-lg border border-amber-200 bg-white/70 p-3 dark:border-white/10 dark:bg-neutral-950/40"><div><p className="font-semibold">{item.manifest.name}</p><p className="text-xs opacity-75">{item.manifest.description} · v{item.manifest.version}</p></div><button type="button" disabled={installAvailable.isPending} onClick={() => installAvailable.mutate(item.sourcePath)} className="rounded-lg bg-cyan-600 px-3 py-2 font-semibold text-white disabled:opacity-50">{installAvailable.isPending ? 'Installing…' : 'Install'}</button></div>)}
{!availableLoading && availableModules.filter((item) => item.manifest.category === 'application').length === 0 && <p className="rounded-lg bg-white/60 p-3 text-xs dark:bg-neutral-950/40">No packages were found beside Aurora.</p>}
</div>
<button type="button" onClick={() => installPackage.mutate()} className="mt-3 text-xs font-semibold underline underline-offset-2">Install from another folder</button>
</div>}
</div> </div>
</div> </div>
@@ -0,0 +1,48 @@
import { Boxes, Download, RefreshCw, Trash2, X } from 'lucide-react'
import { useAvailableModules, useInstallAvailableModule, useInstallModulePackage, useModuleRegistry, useUninstallModulePackage } from '../../hooks/useModules'
function compareVersions(left: string, right: string): number {
const a = left.split(/[.-]/).map((part) => Number(part) || 0)
const b = right.split(/[.-]/).map((part) => Number(part) || 0)
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if ((a[index] ?? 0) !== (b[index] ?? 0)) return (a[index] ?? 0) - (b[index] ?? 0)
}
return 0
}
export function GlobalModuleManager({ onClose }: { onClose: () => void }): React.JSX.Element {
const { data: available = [], isLoading } = useAvailableModules()
const { data: installed = [] } = useModuleRegistry()
const install = useInstallAvailableModule()
const uninstall = useUninstallModulePackage()
const installFromFolder = useInstallModulePackage()
const installedById = new Map(installed.map((module) => [module.id, module]))
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/60 p-4 backdrop-blur-sm">
<div className="flex max-h-[min(720px,calc(100vh-32px))] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.3)] dark:border-white/10 dark:bg-neutral-950">
<header className="flex items-center justify-between border-b border-neutral-200 px-5 py-4 dark:border-white/10">
<div><h2 className="font-semibold">Modules</h2><p className="text-xs text-neutral-500 dark:text-neutral-400">Install and manage Aurora application modules.</p></div>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 text-neutral-400 hover:bg-neutral-100 dark:hover:bg-white/10"><X size={17}/></button>
</header>
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? <p className="text-sm text-neutral-500">Loading available modules</p> : available.length === 0 ? <div className="rounded-xl border border-dashed border-neutral-300 p-6 text-center dark:border-white/10"><Boxes className="mx-auto text-cyan-500"/><p className="mt-3 font-semibold">No modules found</p><p className="mt-1 text-sm text-neutral-500">Place module packages beside Aurora or choose a local package folder.</p></div> : <div className="grid gap-3">
{available.map(({ manifest, sourcePath }) => {
const current = installedById.get(manifest.id)
const updateAvailable = current ? compareVersions(manifest.version, current.version) > 0 : false
const busy = install.isPending || uninstall.isPending
return <article key={manifest.id} className="flex items-center gap-4 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.04]">
<span className="grid size-11 flex-shrink-0 place-items-center rounded-xl bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300"><Boxes size={20}/></span>
<div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h3 className="font-semibold">{manifest.name}</h3><span className="rounded-full bg-neutral-200 px-2 py-0.5 text-[10px] uppercase tracking-wide text-neutral-600 dark:bg-white/10 dark:text-neutral-300">{current ? `Installed ${current.version}` : `Available ${manifest.version}`}</span></div><p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{manifest.description}</p></div>
<div className="flex flex-shrink-0 gap-2">
{!current && <button type="button" disabled={busy} onClick={() => install.mutate(sourcePath)} className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-50"><Download size={14}/>Install</button>}
{current && <button type="button" disabled={busy} title={updateAvailable ? `Update to ${manifest.version}` : 'Reinstall the current version'} onClick={() => install.mutate(sourcePath)} className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-40"><RefreshCw size={14}/>Update</button>}
{current && <button type="button" disabled={busy} onClick={() => { if (confirm(`Uninstall ${manifest.name}? Existing project files and databases will not be removed.`)) uninstall.mutate(manifest.id) }} className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 px-3 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-400/20 dark:text-red-300 dark:hover:bg-red-400/10"><Trash2 size={14}/>Uninstall</button>}
</div>
</article>
})}
</div>}
</div>
<footer className="flex justify-between border-t border-neutral-200 bg-neutral-50 px-5 py-3 dark:border-white/10 dark:bg-white/[0.03]"><button type="button" onClick={() => installFromFolder.mutate()} className="text-sm font-medium text-neutral-500 hover:text-cyan-700 dark:hover:text-cyan-300">Install from folder</button><button type="button" onClick={onClose} className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium dark:border-white/10">Close</button></footer>
</div>
</div>
}