/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ import { FunctionComponent, useContext, useEffect, useState } from 'react'; import { Typography } from '@mui/material'; import { Namespace, isError, Extension, ErrorResult } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { ExtensionCardList } from '../extension/extension-card-list'; /** * Retrieves the full detail for a single extension of a namespace. The caller decides which endpoint * to use, e.g. the public registry API (active extensions only) or the admin API (also returns * inactive/soft-deleted extensions). Receives both the extension `name` and its metadata `url` so the * implementation can use whichever it needs. */ export type FetchNamespaceExtension = ( abortController: AbortController, extension: { name: string; url: string } ) => Promise>; /** * Generic list of the extensions published under a namespace. It renders each extension as a card * (inactive extensions are rendered greyed out by the card itself). The extension detail lookup is * injected via {@link FetchNamespaceExtension} so the same component can be reused with different * endpoints on the user and admin surfaces. */ export const NamespaceExtensionList: FunctionComponent = props => { const [extensions, setExtensions] = useState(); const [loading, setLoading] = useState(true); const context = useContext(MainContext); const fetchExtension: FetchNamespaceExtension = props.fetchExtension ?? ((abortController, extension) => context.service.getExtensionDetail(abortController, extension.url)); // A single effect keyed on the namespace name owns the fetch lifecycle: it resets state, loads the // extensions, and creates a fresh AbortController each run so that switching namespaces (or unmounting) // aborts the previous namespace's in-flight requests instead of letting a stale response overwrite the // current one. (Aborts are ignored by the error handler, so this never surfaces a spurious error.) useEffect(() => { const abortController = new AbortController(); setExtensions(undefined); setLoading(true); updateExtensions(abortController); return () => abortController.abort(); }, [props.namespace.name]); const updateExtensions = async (abortController: AbortController): Promise => { const entries = Object.keys(props.namespace.extensions).map((name: string) => ({ name, url: props.namespace.extensions[name] })); const getExtension = async (entry: { name: string; url: string }) => { try { const result = await fetchExtension(abortController, entry); if (isError(result)) { throw result; } return result; } catch (error) { context.handleError(error); return undefined; } }; const extensionUnfiltered = await Promise.all(entries.map(getExtension)); const extensions = extensionUnfiltered.filter(e => e != null) as Extension[]; setExtensions(extensions); setLoading(false); }; return ( <> Extensions {extensions && extensions.length > 0 ? ( ) : ( No extensions published under this namespace yet. )} ); }; export interface NamespaceExtensionListProps { namespace: Namespace; // Endpoint used to retrieve each extension's detail. Defaults to the public registry API. fetchExtension?: FetchNamespaceExtension; canDelete?: boolean; // Base route each extension card links to. Supplied by the caller. routePrefix: string; }