Add add-on management: registry browser, install, remove (step 5)

Browse the ~270-entry DDEV add-on registry with search, install via
`ddev addon get`, remove via `ddev addon remove` — both routed through
the streaming commandRunner. `addon get`/`remove` support `--project
<name>` directly (unlike snapshot restore), so no cwd trick needed
here. Verified working with the project stopped, per the plan's
requirement.

Found and fixed a real bug via live UI testing: `ddev addon list
--installed --json-output` omits the `raw` key entirely when nothing
is installed (unlike `ddev list`/`ddev snapshot --list`, which include
`raw: null`). The shared runDdevRead helper treated that as an error,
so after removing the last add-on the query errored on refetch and
React Query kept showing the stale cached row instead of clearing it.
Split the helper into runDdevRead (strict, for describeProject where
missing data is a real error) and runDdevReadList (treats missing raw
as empty, for every list-style command) and fixed all four list call
sites to use it.

Verified end-to-end via CDP-driven clicks against the real scratch
project: search filtering, install (streamed output, confirmed via
`ddev addon list --installed` on the CLI), and remove — including
re-confirming after the fix that the installed-add-ons table actually
clears instead of showing stale data.
This commit is contained in:
R3ap3R
2026-08-03 00:02:51 -05:00
parent 9f67b13a47
commit ebfec5f787
9 changed files with 334 additions and 5 deletions
+37 -5
View File
@@ -1,6 +1,12 @@
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import type { DdevProjectDetail, DdevProjectSummary, DdevSnapshot } from '../shared/types' import type {
DdevAddonRegistryEntry,
DdevInstalledAddon,
DdevProjectDetail,
DdevProjectSummary,
DdevSnapshot
} from '../shared/types'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -57,7 +63,8 @@ async function execDdev<T>(args: string[], cwd?: string): Promise<DdevJsonEnvelo
return envelope return envelope
} }
// Read commands (list/describe): the payload lives under `raw`. // Read commands where `raw` is always expected on success (describe): throws
// if it's missing, since that indicates something actually went wrong.
async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> { async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> {
const envelope = await execDdev<T>(args, cwd) const envelope = await execDdev<T>(args, cwd)
if (envelope.raw === undefined) { if (envelope.raw === undefined) {
@@ -68,6 +75,15 @@ async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> {
return envelope.raw return envelope.raw
} }
// List-style read commands are inconsistent about `raw` when there's nothing
// to report: `ddev list`/`ddev snapshot --list` include `raw: null`, but
// `ddev addon list --installed` omits `raw` entirely for "no add-ons found".
// Treat a missing `raw` as "empty" here rather than an error.
async function runDdevReadList<T>(args: string[], cwd?: string): Promise<T | undefined> {
const envelope = await execDdev<T>(args, cwd)
return envelope.raw
}
// ddev sometimes prints non-JSON progress lines before the final JSON envelope // ddev sometimes prints non-JSON progress lines before the final JSON envelope
// (opt-in prompts, deprecation notices); the envelope is always the last line. // (opt-in prompts, deprecation notices); the envelope is always the last line.
function tryParseLastJsonLine<T>(output: string): T | null { function tryParseLastJsonLine<T>(output: string): T | null {
@@ -85,7 +101,7 @@ function tryParseLastJsonLine<T>(output: string): T | null {
} }
export async function listProjects(): Promise<DdevProjectSummary[]> { export async function listProjects(): Promise<DdevProjectSummary[]> {
const raw = await runDdevRead<DdevProjectSummary[] | null>(['list']) const raw = await runDdevReadList<DdevProjectSummary[] | null>(['list'])
return raw ?? [] return raw ?? []
} }
@@ -97,9 +113,25 @@ export async function describeProject(name: string): Promise<DdevProjectDetail>
// resolve the project, so every snapshot command runs with cwd = approot // resolve the project, so every snapshot command runs with cwd = approot
// rather than passing the project name positionally like other commands. // rather than passing the project name positionally like other commands.
export async function listSnapshots(name: string, approot: string): Promise<DdevSnapshot[]> { export async function listSnapshots(name: string, approot: string): Promise<DdevSnapshot[]> {
const raw = await runDdevRead<Record<string, DdevSnapshot[] | null>>( const raw = await runDdevReadList<Record<string, DdevSnapshot[] | null>>(
['snapshot', '--list'], ['snapshot', '--list'],
approot approot
) )
return raw[name] ?? [] return raw?.[name] ?? []
}
export async function listAddonRegistry(): Promise<DdevAddonRegistryEntry[]> {
const raw = await runDdevReadList<DdevAddonRegistryEntry[] | null>(['addon', 'list'])
return raw ?? []
}
export async function listInstalledAddons(name: string): Promise<DdevInstalledAddon[]> {
const raw = await runDdevReadList<DdevInstalledAddon[] | null>([
'addon',
'list',
'--installed',
'--project',
name
])
return raw ?? []
} }
+2
View File
@@ -5,6 +5,7 @@ import icon from '../../resources/icon.png?asset'
import { registerProjectsIpc } from './ipc/projects' import { registerProjectsIpc } from './ipc/projects'
import { registerTerminalIpc } from './ipc/terminal' import { registerTerminalIpc } from './ipc/terminal'
import { registerDatabaseIpc } from './ipc/database' import { registerDatabaseIpc } from './ipc/database'
import { registerAddonsIpc } from './ipc/addons'
function createWindow(): void { function createWindow(): void {
// Create the browser window. // Create the browser window.
@@ -56,6 +57,7 @@ app.whenReady().then(() => {
registerProjectsIpc() registerProjectsIpc()
registerTerminalIpc() registerTerminalIpc()
registerDatabaseIpc() registerDatabaseIpc()
registerAddonsIpc()
createWindow() createWindow()
+16
View File
@@ -0,0 +1,16 @@
import { ipcMain } from 'electron'
import { listAddonRegistry, listInstalledAddons } from '../ddev'
import { runStreamed } from '../commandRunner'
export function registerAddonsIpc(): void {
ipcMain.handle('addons:listRegistry', () => listAddonRegistry())
ipcMain.handle('addons:listInstalled', (_event, name: string) => listInstalledAddons(name))
ipcMain.handle('addons:install', (event, operationId: string, name: string, addonRepo: string) =>
runStreamed(operationId, ['addon', 'get', addonRepo, '--project', name], event.sender)
)
ipcMain.handle('addons:remove', (event, operationId: string, name: string, addonName: string) =>
runStreamed(operationId, ['addon', 'remove', addonName, '--project', name], event.sender)
)
}
+12
View File
@@ -1,6 +1,8 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import type { import type {
DdevAddonRegistryEntry,
DdevInstalledAddon,
DdevProjectDetail, DdevProjectDetail,
DdevProjectSummary, DdevProjectSummary,
DdevSnapshot, DdevSnapshot,
@@ -51,6 +53,16 @@ const api = {
pickImportFile: (): Promise<string | null> => ipcRenderer.invoke('database:pickImportFile'), pickImportFile: (): Promise<string | null> => ipcRenderer.invoke('database:pickImportFile'),
pickExportPath: (defaultFileName: string): Promise<string | null> => pickExportPath: (defaultFileName: string): Promise<string | null> =>
ipcRenderer.invoke('database:pickExportPath', defaultFileName) ipcRenderer.invoke('database:pickExportPath', defaultFileName)
},
addons: {
listRegistry: (): Promise<DdevAddonRegistryEntry[]> =>
ipcRenderer.invoke('addons:listRegistry'),
listInstalled: (name: string): Promise<DdevInstalledAddon[]> =>
ipcRenderer.invoke('addons:listInstalled', name),
install: (operationId: string, name: string, addonRepo: string): Promise<void> =>
ipcRenderer.invoke('addons:install', operationId, name, addonRepo),
remove: (operationId: string, name: string, addonName: string): Promise<void> =>
ipcRenderer.invoke('addons:remove', operationId, name, addonName)
} }
} }
@@ -0,0 +1,97 @@
import { useMemo, useState } from 'react'
import { Star, X } from 'lucide-react'
import { useAddonRegistry, useInstallAddon, useInstalledAddons } from '../../hooks/useAddons'
export function AddonBrowserModal({
name,
onClose
}: {
name: string
onClose: () => void
}): React.JSX.Element {
const [query, setQuery] = useState('')
const { data: registry, isLoading } = useAddonRegistry()
const { data: installed } = useInstalledAddons(name)
const installAddon = useInstallAddon(name)
const installedRepos = useMemo(
() => new Set((installed ?? []).map((a) => a.Repository)),
[installed]
)
const filtered = useMemo(() => {
if (!registry) return []
const q = query.trim().toLowerCase()
if (!q) return registry
return registry.filter(
(entry) =>
entry.title.toLowerCase().includes(q) || entry.description.toLowerCase().includes(q)
)
}, [registry, query])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
<div className="flex h-full w-full max-w-2xl flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h2 className="text-sm font-semibold">Browse Add-ons</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
<div className="border-b border-neutral-200 p-3 dark:border-neutral-800">
<input
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search add-ons…"
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
/>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<p className="p-4 text-sm text-neutral-500">Loading add-on registry</p>
) : filtered.length === 0 ? (
<p className="p-4 text-sm text-neutral-500">No add-ons match &quot;{query}&quot;.</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{filtered.map((entry) => {
const isInstalled = installedRepos.has(entry.title)
return (
<li
key={entry.repo_id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{entry.title}</span>
<span className="flex items-center gap-0.5 text-xs text-neutral-400">
<Star size={11} /> {entry.stars}
</span>
</div>
<p className="truncate text-xs text-neutral-500 dark:text-neutral-400">
{entry.description}
</p>
</div>
<button
type="button"
disabled={isInstalled || installAddon.isPending}
onClick={() => installAddon.mutate(entry.title)}
className="flex-shrink-0 rounded-md border border-neutral-300 px-3 py-1 text-xs font-medium hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
{isInstalled ? 'Installed' : 'Install'}
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { Puzzle, Trash2 } from 'lucide-react'
import { useInstalledAddons, useRemoveAddon } from '../../hooks/useAddons'
import { AddonBrowserModal } from '../addons/AddonBrowserModal'
export function AddonsSection({ name }: { name: string }): React.JSX.Element {
const { data: installed, isLoading } = useInstalledAddons(name)
const removeAddon = useRemoveAddon(name)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
return (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">Add-ons</h3>
<button
type="button"
onClick={() => setIsBrowserOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900"
>
<Puzzle size={12} /> Browse Add-ons
</button>
</div>
{isLoading ? (
<p className="text-sm text-neutral-500">Loading add-ons</p>
) : !installed || installed.length === 0 ? (
<p className="text-sm text-neutral-500">No add-ons installed.</p>
) : (
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<table className="w-full text-sm">
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Version</th>
<th className="px-3 py-2 font-medium">Repository</th>
<th className="px-3 py-2 font-medium" />
</tr>
</thead>
<tbody>
{installed.map((addon) => (
<tr
key={addon.Name}
className="border-t border-neutral-200 dark:border-neutral-800"
>
<td className="px-3 py-2 font-medium">{addon.Name}</td>
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
{addon.Version}
</td>
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
{addon.Repository}
</td>
<td className="px-3 py-2">
<div className="flex justify-end">
<button
type="button"
disabled={removeAddon.isPending}
onClick={() => {
if (window.confirm(`Remove add-on "${addon.Name}"?`)) {
removeAddon.mutate(addon.Name)
}
}}
title="Remove"
className="rounded p-1 text-red-500 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-950"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isBrowserOpen && <AddonBrowserModal name={name} onClose={() => setIsBrowserOpen(false)} />}
</section>
)
}
@@ -7,6 +7,7 @@ import {
} from '../../hooks/useDdev' } from '../../hooks/useDdev'
import { StatusBadge } from './StatusBadge' import { StatusBadge } from './StatusBadge'
import { DatabaseSection } from './DatabaseSection' import { DatabaseSection } from './DatabaseSection'
import { AddonsSection } from './AddonsSection'
export function ProjectDetail({ name }: { name: string }): React.JSX.Element { export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
const { data: project, isLoading, isError, error } = useProjectDetail(name) const { data: project, isLoading, isError, error } = useProjectDetail(name)
@@ -140,6 +141,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
</section> </section>
<DatabaseSection name={project.name} approot={project.approot} /> <DatabaseSection name={project.name} approot={project.approot} />
<AddonsSection name={project.name} />
</div> </div>
) )
} }
+59
View File
@@ -0,0 +1,59 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult
} from '@tanstack/react-query'
import type { DdevAddonRegistryEntry, DdevInstalledAddon } from '@shared/types'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
const installedAddonsKey = (name: string): readonly [string, string, string] =>
['addons', 'installed', name] as const
// The registry is a static-ish list of ~270 third-party repos — cache it for
// a while instead of refetching on every mount.
export function useAddonRegistry(): UseQueryResult<DdevAddonRegistryEntry[], Error> {
return useQuery({
queryKey: ['addons', 'registry'],
queryFn: () => window.api.addons.listRegistry(),
staleTime: 60 * 60 * 1000
})
}
export function useInstalledAddons(name: string): UseQueryResult<DdevInstalledAddon[], Error> {
return useQuery({
queryKey: installedAddonsKey(name),
queryFn: () => window.api.addons.listInstalled(name)
})
}
function beginOperation(label: string): string {
const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
return operationId
}
export function useInstallAddon(name: string): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (addonRepo: string) => {
const operationId = beginOperation(`Install ${addonRepo}`)
await window.api.addons.install(operationId, name, addonRepo)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: installedAddonsKey(name) })
})
}
export function useRemoveAddon(name: string): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (addonName: string) => {
const operationId = beginOperation(`Remove ${addonName}`)
await window.api.addons.remove(operationId, name, addonName)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: installedAddonsKey(name) })
})
}
+29
View File
@@ -72,6 +72,35 @@ export interface DdevSnapshot {
Created: string Created: string
} }
export interface DdevAddonRegistryEntry {
title: string
github_url: string
description: string
user: string
repo: string
repo_id: number
default_branch: string
tag_name: string | null
ddev_version_constraint: string
dependencies: string[] | null
type: string
created_at: string
updated_at: string
workflow_status: string
stars: number
}
export interface DdevInstalledAddon {
Name: string
Repository: string
Version: string
Dependencies: string[] | null
InstallDate: string
ProjectFiles: string[] | null
GlobalFiles: string[] | null
RemovalActions: string[] | null
}
export interface TerminalDataEvent { export interface TerminalDataEvent {
operationId: string operationId: string
stream: 'stdout' | 'stderr' stream: 'stdout' | 'stderr'