From ebfec5f7873bcf85cf2f28756f1db48fbc98bd1a Mon Sep 17 00:00:00 2001 From: R3ap3R Date: Mon, 3 Aug 2026 00:02:51 -0500 Subject: [PATCH] Add add-on management: registry browser, install, remove (step 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- src/main/ddev.ts | 42 +++++++- src/main/index.ts | 2 + src/main/ipc/addons.ts | 16 +++ src/preload/index.ts | 12 +++ .../components/addons/AddonBrowserModal.tsx | 97 +++++++++++++++++++ .../src/components/projects/AddonsSection.tsx | 79 +++++++++++++++ .../src/components/projects/ProjectDetail.tsx | 3 + src/renderer/src/hooks/useAddons.ts | 59 +++++++++++ src/shared/types.ts | 29 ++++++ 9 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 src/main/ipc/addons.ts create mode 100644 src/renderer/src/components/addons/AddonBrowserModal.tsx create mode 100644 src/renderer/src/components/projects/AddonsSection.tsx create mode 100644 src/renderer/src/hooks/useAddons.ts diff --git a/src/main/ddev.ts b/src/main/ddev.ts index 3da18ba..d896c35 100644 --- a/src/main/ddev.ts +++ b/src/main/ddev.ts @@ -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(args: string[], cwd?: string): Promise(args: string[], cwd?: string): Promise { const envelope = await execDdev(args, cwd) if (envelope.raw === undefined) { @@ -68,6 +75,15 @@ async function runDdevRead(args: string[], cwd?: string): Promise { 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(args: string[], cwd?: string): Promise { + const envelope = await execDdev(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(output: string): T | null { @@ -85,7 +101,7 @@ function tryParseLastJsonLine(output: string): T | null { } export async function listProjects(): Promise { - const raw = await runDdevRead(['list']) + const raw = await runDdevReadList(['list']) return raw ?? [] } @@ -97,9 +113,25 @@ export async function describeProject(name: string): Promise // 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 { - const raw = await runDdevRead>( + const raw = await runDdevReadList>( ['snapshot', '--list'], approot ) - return raw[name] ?? [] + return raw?.[name] ?? [] +} + +export async function listAddonRegistry(): Promise { + const raw = await runDdevReadList(['addon', 'list']) + return raw ?? [] +} + +export async function listInstalledAddons(name: string): Promise { + const raw = await runDdevReadList([ + 'addon', + 'list', + '--installed', + '--project', + name + ]) + return raw ?? [] } diff --git a/src/main/index.ts b/src/main/index.ts index 27f3867..8a31fb7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/addons.ts b/src/main/ipc/addons.ts new file mode 100644 index 0000000..e7e449d --- /dev/null +++ b/src/main/ipc/addons.ts @@ -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) + ) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 9c7bdd3..a1c8020 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('database:pickImportFile'), pickExportPath: (defaultFileName: string): Promise => ipcRenderer.invoke('database:pickExportPath', defaultFileName) + }, + addons: { + listRegistry: (): Promise => + ipcRenderer.invoke('addons:listRegistry'), + listInstalled: (name: string): Promise => + ipcRenderer.invoke('addons:listInstalled', name), + install: (operationId: string, name: string, addonRepo: string): Promise => + ipcRenderer.invoke('addons:install', operationId, name, addonRepo), + remove: (operationId: string, name: string, addonName: string): Promise => + ipcRenderer.invoke('addons:remove', operationId, name, addonName) } } diff --git a/src/renderer/src/components/addons/AddonBrowserModal.tsx b/src/renderer/src/components/addons/AddonBrowserModal.tsx new file mode 100644 index 0000000..29473a8 --- /dev/null +++ b/src/renderer/src/components/addons/AddonBrowserModal.tsx @@ -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 ( +
+
+
+

Browse Add-ons

+ +
+
+ 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" + /> +
+
+ {isLoading ? ( +

Loading add-on registry…

+ ) : filtered.length === 0 ? ( +

No add-ons match "{query}".

+ ) : ( +
    + {filtered.map((entry) => { + const isInstalled = installedRepos.has(entry.title) + return ( +
  • +
    +
    + {entry.title} + + {entry.stars} + +
    +

    + {entry.description} +

    +
    + +
  • + ) + })} +
+ )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/projects/AddonsSection.tsx b/src/renderer/src/components/projects/AddonsSection.tsx new file mode 100644 index 0000000..e9328d2 --- /dev/null +++ b/src/renderer/src/components/projects/AddonsSection.tsx @@ -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 ( +
+
+

Add-ons

+ +
+ + {isLoading ? ( +

Loading add-ons…

+ ) : !installed || installed.length === 0 ? ( +

No add-ons installed.

+ ) : ( +
+ + + + + + + + + + {installed.map((addon) => ( + + + + + + + ))} + +
NameVersionRepository +
{addon.Name} + {addon.Version} + + {addon.Repository} + +
+ +
+
+
+ )} + + {isBrowserOpen && setIsBrowserOpen(false)} />} +
+ ) +} diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index 2be2fbc..6a74326 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -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 { + + ) } diff --git a/src/renderer/src/hooks/useAddons.ts b/src/renderer/src/hooks/useAddons.ts new file mode 100644 index 0000000..6c0b4b8 --- /dev/null +++ b/src/renderer/src/hooks/useAddons.ts @@ -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 { + return useQuery({ + queryKey: ['addons', 'registry'], + queryFn: () => window.api.addons.listRegistry(), + staleTime: 60 * 60 * 1000 + }) +} + +export function useInstalledAddons(name: string): UseQueryResult { + 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 { + 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 { + 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) }) + }) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 2a6a5fa..0c6000a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'