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:
+37
-5
@@ -1,6 +1,12 @@
|
||||
import { execFile } from 'child_process'
|
||||
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)
|
||||
|
||||
@@ -57,7 +63,8 @@ async function execDdev<T>(args: string[], cwd?: string): Promise<DdevJsonEnvelo
|
||||
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> {
|
||||
const envelope = await execDdev<T>(args, cwd)
|
||||
if (envelope.raw === undefined) {
|
||||
@@ -68,6 +75,15 @@ async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> {
|
||||
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
|
||||
// (opt-in prompts, deprecation notices); the envelope is always the last line.
|
||||
function tryParseLastJsonLine<T>(output: string): T | null {
|
||||
@@ -85,7 +101,7 @@ function tryParseLastJsonLine<T>(output: string): T | null {
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<DdevProjectSummary[]> {
|
||||
const raw = await runDdevRead<DdevProjectSummary[] | null>(['list'])
|
||||
const raw = await runDdevReadList<DdevProjectSummary[] | null>(['list'])
|
||||
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
|
||||
// rather than passing the project name positionally like other commands.
|
||||
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'],
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import icon from '../../resources/icon.png?asset'
|
||||
import { registerProjectsIpc } from './ipc/projects'
|
||||
import { registerTerminalIpc } from './ipc/terminal'
|
||||
import { registerDatabaseIpc } from './ipc/database'
|
||||
import { registerAddonsIpc } from './ipc/addons'
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
@@ -56,6 +57,7 @@ app.whenReady().then(() => {
|
||||
registerProjectsIpc()
|
||||
registerTerminalIpc()
|
||||
registerDatabaseIpc()
|
||||
registerAddonsIpc()
|
||||
|
||||
createWindow()
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type {
|
||||
DdevAddonRegistryEntry,
|
||||
DdevInstalledAddon,
|
||||
DdevProjectDetail,
|
||||
DdevProjectSummary,
|
||||
DdevSnapshot,
|
||||
@@ -51,6 +53,16 @@ const api = {
|
||||
pickImportFile: (): Promise<string | null> => ipcRenderer.invoke('database:pickImportFile'),
|
||||
pickExportPath: (defaultFileName: string): Promise<string | null> =>
|
||||
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 "{query}".</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'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
import { DatabaseSection } from './DatabaseSection'
|
||||
import { AddonsSection } from './AddonsSection'
|
||||
|
||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
||||
@@ -140,6 +141,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
</section>
|
||||
|
||||
<DatabaseSection name={project.name} approot={project.approot} />
|
||||
|
||||
<AddonsSection name={project.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
@@ -72,6 +72,35 @@ export interface DdevSnapshot {
|
||||
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 {
|
||||
operationId: string
|
||||
stream: 'stdout' | 'stderr'
|
||||
|
||||
Reference in New Issue
Block a user