diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 1803c4846..9b511f188 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -20,7 +20,6 @@ import java.util.LinkedHashMap; import java.util.List; -import java.util.Optional; import java.util.concurrent.TimeUnit; import org.eclipse.openvsx.accesstoken.AccessTokenService; @@ -241,7 +240,8 @@ public List getOwnExtensions() { } var extVersions = repositories.findLatestVersions(user); - var types = new String[]{ DOWNLOAD, MANIFEST, ICON, README, LICENSE, CHANGELOG, VSIXMANIFEST }; + + var types = new String[] { DOWNLOAD, MANIFEST, ICON, README, LICENSE, CHANGELOG, VSIXMANIFEST }; var fileUrls = storageUtil.getFileUrls(extVersions, UrlUtil.getBaseUrl(), types); return extVersions.stream() .map(latest -> { @@ -260,7 +260,7 @@ public List getOwnExtensions() { /** * Add review/scan status information to the extension JSON. - * + *

* This shows users the current state of their extension in simple terms: * - "published" - Extension is active and publicly available * - "under_review" - Extension is being reviewed (validation, scanning, etc.) diff --git a/server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java b/server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java new file mode 100644 index 000000000..80c782b5b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java @@ -0,0 +1,39 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import io.swagger.v3.oas.annotations.media.Schema; + +import static org.eclipse.openvsx.util.TargetPlatform.*; + +/** + * + * @param targetPlatform Name of the target platform + * @param active Whether this target platform version is active + */ +@Schema( + name = "TargetPlatformActive", + description = "Target platform of an extension version and whether it is active" +) +public record TargetPlatformActiveJson( + @Schema(description = "Name of the target platform", allowableValues = { + NAME_WIN32_X64, NAME_WIN32_IA32, NAME_WIN32_ARM64, + NAME_LINUX_X64, NAME_LINUX_ARM64, NAME_LINUX_ARMHF, + NAME_ALPINE_X64, NAME_ALPINE_ARM64, + NAME_DARWIN_X64, NAME_DARWIN_ARM64, + NAME_WEB, NAME_UNIVERSAL + }) + String targetPlatform, + @Schema(description = "Whether this extension version for this target platform is active") + boolean active +) {} diff --git a/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java b/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java index ed11a8f1c..f107de4a9 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java @@ -9,4 +9,6 @@ * ****************************************************************************** */ package org.eclipse.openvsx.json; -public record VersionTargetPlatformsJson(String version, String[] targetPlatforms) {} +import java.util.List; + +public record VersionTargetPlatformsJson(String version, List targetPlatforms) {} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java index 1476aee69..0f9e957a5 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java @@ -12,6 +12,7 @@ import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.QueryRequest; +import org.eclipse.openvsx.json.TargetPlatformActiveJson; import org.eclipse.openvsx.json.TargetPlatformVersionJson; import org.eclipse.openvsx.json.VersionTargetPlatformsJson; import org.eclipse.openvsx.util.TargetPlatform; @@ -545,6 +546,11 @@ public List findTargetPlatformsGroupedByVersion(Exte EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(), EXTENSION_VERSION.TARGET_PLATFORM.asc() ); + var targetPlatformsActive = DSL.arrayAgg(EXTENSION_VERSION.ACTIVE) + .orderBy( + EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(), + EXTENSION_VERSION.TARGET_PLATFORM.asc() + ); return dsl.select( EXTENSION_VERSION.SEMVER_MAJOR, @@ -552,7 +558,8 @@ public List findTargetPlatformsGroupedByVersion(Exte EXTENSION_VERSION.SEMVER_PATCH, EXTENSION_VERSION.SEMVER_IS_PRE_RELEASE, EXTENSION_VERSION.VERSION, - targetPlatforms + targetPlatforms, + targetPlatformsActive ) .from(EXTENSION_VERSION) .where(EXTENSION_VERSION.EXTENSION_ID.eq(extension.getId())) @@ -571,9 +578,10 @@ public List findTargetPlatformsGroupedByVersion(Exte EXTENSION_VERSION.VERSION.asc() ) .fetch() - .map(row -> new VersionTargetPlatformsJson( + .map(row -> toVersionTargetPlatformsJson( row.get(EXTENSION_VERSION.VERSION), - row.get(targetPlatforms) + row.get(targetPlatforms), + row.get(targetPlatformsActive) )); } @@ -583,6 +591,11 @@ public List findTargetPlatformsGroupedByVersion(Exte EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(), EXTENSION_VERSION.TARGET_PLATFORM.asc() ); + var targetPlatformsActive = DSL.arrayAgg(EXTENSION_VERSION.ACTIVE) + .orderBy( + EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(), + EXTENSION_VERSION.TARGET_PLATFORM.asc() + ); return dsl.select( EXTENSION_VERSION.SEMVER_MAJOR, @@ -590,7 +603,8 @@ public List findTargetPlatformsGroupedByVersion(Exte EXTENSION_VERSION.SEMVER_PATCH, EXTENSION_VERSION.SEMVER_IS_PRE_RELEASE, EXTENSION_VERSION.VERSION, - targetPlatforms + targetPlatforms, + targetPlatformsActive ) .from(EXTENSION_VERSION) .join(PERSONAL_ACCESS_TOKEN).on(PERSONAL_ACCESS_TOKEN.ID.eq(EXTENSION_VERSION.PUBLISHED_WITH_ID)) @@ -611,12 +625,22 @@ public List findTargetPlatformsGroupedByVersion(Exte EXTENSION_VERSION.VERSION.asc() ) .fetch() - .map(row -> new VersionTargetPlatformsJson( + .map(row -> toVersionTargetPlatformsJson( row.get(EXTENSION_VERSION.VERSION), - row.get(targetPlatforms) + row.get(targetPlatforms), + row.get(targetPlatformsActive) )); } + private VersionTargetPlatformsJson toVersionTargetPlatformsJson(String version, String[] targetPlatforms, Boolean[] active) { + var platforms = new ArrayList(targetPlatforms.length); + for (int i = 0; i < targetPlatforms.length; i++) { + platforms.add(new TargetPlatformActiveJson(targetPlatforms[i], active[i])); + } + + return new VersionTargetPlatformsJson(version, platforms); + } + public List findVersionsForUrls(Extension extension, String targetPlatform, String version) { var query = dsl.selectQuery(); query.addSelect( diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index e6609f384..0d885d681 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -7,6 +7,7 @@ This change log covers only the frontend library (webui) of Open VSX. ### Added - Support searching users and managing their roles in the admin dashboard ([#1847](https://github.com/eclipse-openvsx/openvsx/pull/1847)) +- Added an extension details page to admin dashboard and user settings ([#1939](https://github.com/eclipse-openvsx/openvsx/pull/1939)) ### Changed diff --git a/webui/src/components/extension/extension-delete-all-versions-dialog.tsx b/webui/src/components/extension/extension-delete-all-versions-dialog.tsx new file mode 100644 index 000000000..2724a25ac --- /dev/null +++ b/webui/src/components/extension/extension-delete-all-versions-dialog.tsx @@ -0,0 +1,70 @@ +/****************************************************************************** + * 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, useState } from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; +import { ButtonWithProgress } from '../button-with-progress'; +import { MainContext } from '../../context'; +import { Extension, VersionTargetPlatforms } from '../../extension-registry-types'; +import { VersionDeleteTarget } from './extension-version-delete-dialog'; + +export const DeleteAllVersionsDialog: FunctionComponent = props => { + const { handleError } = useContext(MainContext); + const [working, setWorking] = useState(false); + + const handleRemove = async () => { + try { + setWorking(true); + const targets: VersionDeleteTarget[] = props.versions.flatMap(v => + v.targetPlatforms.map(({ targetPlatform }) => ({ version: v.version, targetPlatform })) + ); + await props.onRemove(targets); + props.onDeleted(); + props.onClose(); + } catch (err) { + handleError(err); + } finally { + setWorking(false); + } + }; + + return ( +

+ Delete all versions of {props.extension.displayName ?? props.extension.name}? + + + This will permanently remove {props.versions.length} version + {props.versions.length === 1 ? '' : 's'} of this extension across all target platforms. This action + cannot be undone. + + + + + + Delete All Versions + + + + ); +}; + +export interface DeleteAllVersionsDialogProps { + open: boolean; + onClose: () => void; + extension: Extension; + versions: VersionTargetPlatforms[]; + onRemove: (targets: VersionDeleteTarget[]) => Promise; + onDeleted: () => void; +} diff --git a/webui/src/components/extension/extension-detail-view.tsx b/webui/src/components/extension/extension-detail-view.tsx new file mode 100644 index 000000000..7bf2cf7b8 --- /dev/null +++ b/webui/src/components/extension/extension-detail-view.tsx @@ -0,0 +1,101 @@ +/****************************************************************************** + * 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, ReactNode, useEffect, useState } from 'react'; +import { Box, Button, Divider, Stack, Typography } from '@mui/material'; +import { Link as RouteLink } from 'react-router-dom'; +import { Extension, VERSION_ALIASES, VersionTargetPlatforms } from '../../extension-registry-types'; +import { ExtensionHeader } from './extension-header'; +import { ExtensionStatusChips } from './extension-status-chips'; +import { ExtensionVersionTable } from './extension-version-table'; +import { DeleteVersionDialog, VersionDeleteTarget } from './extension-version-delete-dialog'; +import { DeleteAllVersionsDialog } from './extension-delete-all-versions-dialog'; +import { ExtensionDetailRoutes } from '../../pages/extension-detail/extension-detail-routes'; +import { createRoute } from '../../utils'; + +export const ExtensionDetailView: FunctionComponent = props => { + const { extension, actions, onRemoveVersion, onVersionDeleted } = props; + + const [page, setPage] = useState(0); + const [deleteDialogVersion, setDeleteDialogVersion] = useState(null); + const [deleteAllOpen, setDeleteAllOpen] = useState(false); + + useEffect(() => { + setPage(0); + }, [extension]); + + const publicRoute = createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name]); + const allVersions = (extension.allTargetPlatformVersions ?? []).filter(v => !VERSION_ALIASES.includes(v.version)); + + return ( + + + {extension.description && ( + + {extension.description} + + )} + + + + + + {actions} + + + Versions + + + {deleteDialogVersion && ( + setDeleteDialogVersion(null)} + extension={extension} + version={deleteDialogVersion} + onRemove={onRemoveVersion} + onDeleted={onVersionDeleted} + /> + )} + {deleteAllOpen && ( + setDeleteAllOpen(false)} + extension={extension} + versions={allVersions} + onRemove={onRemoveVersion} + onDeleted={onVersionDeleted} + /> + )} + + ); +}; + +export interface ExtensionDetailViewProps { + extension: Extension; + actions?: ReactNode; + onRemoveVersion: (targets: VersionDeleteTarget[]) => Promise; + onVersionDeleted: () => void; +} diff --git a/webui/src/components/extension/extension-header.tsx b/webui/src/components/extension/extension-header.tsx new file mode 100644 index 000000000..8f641ea23 --- /dev/null +++ b/webui/src/components/extension/extension-header.tsx @@ -0,0 +1,33 @@ +/****************************************************************************** + * 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 } from 'react'; +import { Box, Typography } from '@mui/material'; +import { Extension } from '../../extension-registry-types'; +import { ExtensionIcon } from './extension-icon'; + +export const ExtensionHeader: FunctionComponent = ({ extension }) => ( + + + + {extension.displayName ?? extension.name} + + {extension.namespace}.{extension.name} · v{extension.version} + + + +); + +export interface ExtensionHeaderProps { + extension: Extension; +} diff --git a/webui/src/components/extension/extension-icon.tsx b/webui/src/components/extension/extension-icon.tsx new file mode 100644 index 000000000..533d31b5a --- /dev/null +++ b/webui/src/components/extension/extension-icon.tsx @@ -0,0 +1,42 @@ +/****************************************************************************** + * 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 } from 'react'; +import { Box, SxProps, Theme } from '@mui/material'; +import { MainContext } from '../../context'; +import { Extension, SearchEntry } from '../../extension-registry-types'; +import { useExtensionIcon } from './use-extension-icon'; + +/** + * Renders an extension's icon, falling back to the configured default icon + * while the real one loads or when the extension has none. + */ +export const ExtensionIcon: FunctionComponent = ({ extension, alt, sx }) => { + const { pageSettings } = useContext(MainContext); + const icon = useExtensionIcon(extension); + + return ( + + ); +}; + +export interface ExtensionIconProps { + extension: Extension | SearchEntry; + alt?: string; + sx?: SxProps; +} diff --git a/webui/src/components/extension/extension-status-chips.tsx b/webui/src/components/extension/extension-status-chips.tsx new file mode 100644 index 000000000..c5acc6c91 --- /dev/null +++ b/webui/src/components/extension/extension-status-chips.tsx @@ -0,0 +1,29 @@ +/****************************************************************************** + * 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 } from 'react'; +import { Chip, Stack } from '@mui/material'; +import { Extension } from '../../extension-registry-types'; + +export const ExtensionStatusChips: FunctionComponent = ({ extension }) => ( + + {extension.deprecated && } + {extension.active === false && } + {extension.reviewStatus === 'under_review' && } + {extension.reviewStatus === 'rejected' && } + +); + +export interface ExtensionStatusChipsProps { + extension: Extension; +} diff --git a/webui/src/components/extension/extension-version-delete-dialog.tsx b/webui/src/components/extension/extension-version-delete-dialog.tsx new file mode 100644 index 000000000..850434823 --- /dev/null +++ b/webui/src/components/extension/extension-version-delete-dialog.tsx @@ -0,0 +1,142 @@ +/****************************************************************************** + * 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 { ChangeEvent, FunctionComponent, useContext, useEffect, useState } from 'react'; +import { + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + FormGroup +} from '@mui/material'; +import { Extension, TargetPlatformVersion, VersionTargetPlatforms } from '../../extension-registry-types'; +import { MainContext } from '../../context'; +import { getTargetPlatformDisplayName } from '../../utils'; +import { + VERSION_DIALOG_WILDCARD, + buildVersionDialogItems, + handleVersionDialogChange +} from './extension-version-dialog-shared'; + +export interface VersionDeleteTarget { + version: string; + targetPlatform: string; +} + +export const DeleteVersionDialog: FunctionComponent = props => { + const { handleError } = useContext(MainContext); + const [items, setItems] = useState([]); + const [working, setWorking] = useState(false); + + useEffect(() => { + setItems(buildVersionDialogItems(props.version.targetPlatforms.map(tp => tp.targetPlatform))); + }, [props.version]); + + const handleChange = (event: ChangeEvent) => { + setItems(prev => handleVersionDialogChange(event, prev)); + }; + + const selected = items.filter(i => i.targetPlatform !== VERSION_DIALOG_WILDCARD && i.checked); + + const handleRemove = async () => { + try { + setWorking(true); + await props.onRemove( + selected.map(({ targetPlatform }) => ({ version: props.version.version, targetPlatform })) + ); + props.onDeleted(); + props.onClose(); + } catch (err) { + handleError(err); + } finally { + setWorking(false); + } + }; + + const allItem = items.find(i => i.targetPlatform === VERSION_DIALOG_WILDCARD); + const platformItems = items.filter(i => i.targetPlatform !== VERSION_DIALOG_WILDCARD); + + return ( + + + Delete version {props.version.version} of {props.extension.displayName ?? props.extension.name} + + + + {allItem && ( + + } + label='All Targets' + /> + )} + {platformItems.map(item => ( + + } + label={getTargetPlatformDisplayName(item.targetPlatform) || item.targetPlatform} + /> + ))} + + + + + + + {working && ( + + )} + + + + ); +}; + +export interface DeleteVersionDialogProps { + open: boolean; + onClose: () => void; + extension: Extension; + version: VersionTargetPlatforms; + onRemove: (targets: VersionDeleteTarget[]) => Promise; + onDeleted: () => void; +} diff --git a/webui/src/components/extension/extension-version-dialog-shared.tsx b/webui/src/components/extension/extension-version-dialog-shared.tsx new file mode 100644 index 000000000..bb6aca491 --- /dev/null +++ b/webui/src/components/extension/extension-version-dialog-shared.tsx @@ -0,0 +1,35 @@ +/****************************************************************************** + * 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 { ChangeEvent } from 'react'; +import { TargetPlatformVersion } from '../../extension-registry-types'; + +export const VERSION_DIALOG_WILDCARD = '*'; + +export const buildVersionDialogItems = (targetPlatforms: string[]): TargetPlatformVersion[] => [ + { targetPlatform: VERSION_DIALOG_WILDCARD, version: VERSION_DIALOG_WILDCARD, checked: true }, + ...targetPlatforms.map(tp => ({ targetPlatform: tp, version: VERSION_DIALOG_WILDCARD, checked: true })) +]; + +export const handleVersionDialogChange = ( + event: ChangeEvent, + prev: TargetPlatformVersion[] +): TargetPlatformVersion[] => { + const { name, checked } = event.target; + if (name === VERSION_DIALOG_WILDCARD) { + return prev.map(item => ({ ...item, checked })); + } + const next = prev.map(item => (item.targetPlatform === name ? { ...item, checked } : item)); + const allChecked = next.filter(i => i.targetPlatform !== VERSION_DIALOG_WILDCARD).every(i => i.checked); + return next.map(i => (i.targetPlatform === VERSION_DIALOG_WILDCARD ? { ...i, checked: allChecked } : i)); +}; diff --git a/webui/src/components/extension/extension-version-table.tsx b/webui/src/components/extension/extension-version-table.tsx new file mode 100644 index 000000000..5c07cbd27 --- /dev/null +++ b/webui/src/components/extension/extension-version-table.tsx @@ -0,0 +1,105 @@ +/****************************************************************************** + * 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 } from 'react'; +import { + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableFooter, + TableHead, + TablePagination, + TableRow, + Typography +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { VersionTargetPlatforms } from '../../extension-registry-types'; + +const PAGE_SIZE = 20; + +export const ExtensionVersionTable: FunctionComponent = ({ + versions, + page, + onPageChange, + onDeleteVersion +}) => { + const pagedVersions = versions.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); + + return ( + + + + + + Version + + Target Platforms + + + + + {pagedVersions.map(v => ( + + {v.version} + + {v.targetPlatforms.map((tp, index) => ( + + + {tp.targetPlatform} + + {index < v.targetPlatforms.length - 1 ? ', ' : ''} + + ))} + + + onDeleteVersion(v)}> + + + + + ))} + {versions.length === 0 && ( + + + No version information available. + + + )} + + + + onPageChange(newPage)} + /> + + +
+
+ ); +}; + +export interface ExtensionVersionTableProps { + versions: VersionTargetPlatforms[]; + page: number; + onPageChange: (page: number) => void; + onDeleteVersion: (version: VersionTargetPlatforms) => void; +} diff --git a/webui/src/components/extension/use-extension-icon.ts b/webui/src/components/extension/use-extension-icon.ts new file mode 100644 index 000000000..0fcafb2f4 --- /dev/null +++ b/webui/src/components/extension/use-extension-icon.ts @@ -0,0 +1,66 @@ +/****************************************************************************** + * 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 { useContext, useEffect, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { MainContext } from '../../context'; +import { controllerFromSignal } from '../../query-client'; +import { Extension, SearchEntry } from '../../extension-registry-types'; + +/** + * Loads an extension's icon as an object URL. Caching is disabled (`gcTime`/ + * `staleTime` 0) so a fresh URL is fetched per extension and never reused + * across mounts, which would risk serving an already-revoked object URL. + * The previously created object URL is revoked whenever it is replaced by a + * new one and on unmount. + */ +export const useExtensionIcon = (extension: Extension | SearchEntry): string | undefined => { + const { service } = useContext(MainContext); + const targetPlatform = 'targetPlatform' in extension ? extension.targetPlatform : undefined; + const { data } = useQuery({ + queryKey: [ + 'extension-icon', + extension.namespace, + extension.name, + extension.version, + targetPlatform, + extension.files.icon + ], + queryFn: async ({ signal }) => { + const icon = await service.getExtensionIcon(controllerFromSignal(signal), extension); + // useQuery forbids `undefined`; normalise the "no icon" case to null. + return icon ?? null; + }, + gcTime: 0, + staleTime: 0 + }); + + const previousUrl = useRef(null); + useEffect(() => { + if (previousUrl.current && previousUrl.current !== data) { + URL.revokeObjectURL(previousUrl.current); + } + previousUrl.current = data ?? null; + }, [data]); + + useEffect( + () => () => { + if (previousUrl.current) { + URL.revokeObjectURL(previousUrl.current); + } + }, + [] + ); + + return data ?? undefined; +}; diff --git a/webui/src/components/scan-admin/scan-card/scan-card-header.tsx b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx index 5a6fecfe2..2335eae08 100644 --- a/webui/src/components/scan-admin/scan-card/scan-card-header.tsx +++ b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx @@ -60,13 +60,7 @@ export const ScanCardHeader: FC = ({ scan }) => { const hasValidIcon = scan.extensionIcon && !imageError; const extensionRoute = createRoute([ExtensionDetailRoutes.ROOT, scan.namespace, scan.extensionName]); - const adminRoute = createRoute( - [AdminDashboardRoutes.ROOT, 'extensions'], - [ - { key: 'namespace', value: scan.namespace }, - { key: 'extension', value: scan.extensionName } - ] - ); + const adminRoute = createRoute([AdminDashboardRoutes.ROOT, 'extensions', scan.namespace, scan.extensionName]); return ( <> diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 00a0709b3..7c091b796 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -581,7 +581,7 @@ export class ExtensionRegistryService { headers[csrfToken.header] = csrfToken.value; } - return sendRequest({ + return sendNonRetriableRequest({ abortController, method: 'GET', credentials: true, @@ -590,11 +590,12 @@ export class ExtensionRegistryService { }); } - async deleteExtensions( - abortController: AbortController, - req: { namespace: string; extension: string; targetPlatformVersions?: object[] } - ): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); + async deleteExtensions(req: { + namespace: string; + extension: string; + targetPlatformVersions?: object[]; + }): Promise> { + const csrfResponse = await this.getCsrfToken(); const headers: Record = { 'Content-Type': 'application/json;charset=UTF-8' }; @@ -603,8 +604,7 @@ export class ExtensionRegistryService { headers[csrfToken.header] = csrfToken.value; } - return sendRequest({ - abortController, + return sendNonRetriableRequest({ method: 'POST', credentials: true, endpoint: createAbsoluteURL([this.serverUrl, 'user', 'extension', req.namespace, req.extension, 'delete']), diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index f70b4bfcb..1159602a3 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -132,9 +132,14 @@ export interface ExtensionReference { version?: string; } +export interface TargetPlatformActive { + targetPlatform: string; + active: boolean; +} + export interface VersionTargetPlatforms { version: string; - targetPlatforms: string[]; + targetPlatforms: TargetPlatformActive[]; } export type StarRating = 1 | 2 | 3 | 4 | 5; diff --git a/webui/src/other-pages.tsx b/webui/src/other-pages.tsx index eb66835a0..789cae218 100644 --- a/webui/src/other-pages.tsx +++ b/webui/src/other-pages.tsx @@ -131,7 +131,7 @@ export const OtherPages: FunctionComponent = props => { element={} /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/extension-admin.tsx b/webui/src/pages/admin-dashboard/extension-admin.tsx index d497562e2..28be391a3 100644 --- a/webui/src/pages/admin-dashboard/extension-admin.tsx +++ b/webui/src/pages/admin-dashboard/extension-admin.tsx @@ -8,47 +8,47 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, useState, useContext, useEffect } from 'react'; -import { SearchListContainer } from './search-list-container'; -import { ExtensionListSearchfield } from '../extension-list/extension-list-searchfield'; +import { FunctionComponent, useContext, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { Button, Typography } from '@mui/material'; + import { MainContext } from '../../context'; -import { TargetPlatformVersion } from '../../extension-registry-types'; -import { ExtensionVersionContainer } from './extension-version-container'; +import { SearchListContainer } from './search-list-container'; import { StyledInput } from './namespace-input'; -import useQueryParam from '../../hooks/scan-admin/use-query-params-state'; +import { ExtensionListSearchfield } from '../extension-list/extension-list-searchfield'; import { useAdminExtension, useDeleteExtension } from './use-extension-admin'; +import { ExtensionDetailView } from '../../components/extension/extension-detail-view'; +import { AdminDashboardRoutes } from './admin-dashboard-routes'; +import { createRoute } from '../../utils'; export const ExtensionAdmin: FunctionComponent = () => { const { handleError } = useContext(MainContext); + const navigate = useNavigate(); - const [extensionValue, setExtensionValue] = useQueryParam('extension', ''); - const handleExtensionChange = (value: string) => { - setExtensionValue(value); - }; + const { namespace: nsParam, extension: extParam } = useParams<{ namespace?: string; extension?: string }>(); - const [namespaceValue, setNamespaceValue] = useQueryParam('namespace', ''); - const handleNamespaceChange = (value: string) => { - setNamespaceValue(value); - }; + const [namespaceValue, setNamespaceValue] = useState(nsParam ?? ''); + const [extensionValue, setExtensionValue] = useState(extParam ?? ''); + + useEffect(() => { + setNamespaceValue(nsParam ?? ''); + setExtensionValue(extParam ?? ''); + }, [nsParam, extParam]); const [validationError, setValidationError] = useState(''); const [extensionFieldError, setExtensionFieldError] = useState(false); const [namespaceFieldError, setNamespaceFieldError] = useState(false); - // The lookup runs only after a validated search; null keeps the query idle. - const [target, setTarget] = useState<{ namespace: string; extension: string } | null>(null); + const target = nsParam && extParam ? { namespace: nsParam, extension: extParam } : null; const { data, isFetching: loading, error: queryError, refetch } = useAdminExtension(target); const { mutateAsync: deleteExtension } = useDeleteExtension(); const is404 = !!queryError && (queryError as { status?: number }).status === 404; - // Hide a previously loaded extension once a lookup fails. const extension = queryError ? undefined : data; const fetchError = is404 ? `Extension not found: ${target?.namespace}.${target?.extension}` : ''; const error = validationError || fetchError; - // Non-404 lookup failures keep flowing through the global error dialog. useEffect(() => { if (queryError && !is404) { handleError(queryError); @@ -69,30 +69,13 @@ export const ExtensionAdmin: FunctionComponent = () => { } setExtensionFieldError(false); setValidationError(''); - if (target && target.namespace === namespaceValue && target.extension === extensionValue) { - // Re-submitting the same coordinates should force a fresh lookup. + if (nsParam === namespaceValue && extParam === extensionValue) { refetch(); } else { - setTarget({ namespace: namespaceValue, extension: extensionValue }); + navigate(createRoute([AdminDashboardRoutes.EXTENSION_ADMIN, namespaceValue, extensionValue])); } }; - const onRemove = async (targetPlatformVersions?: TargetPlatformVersion[]) => { - if (extension == null) { - return; - } - - await deleteExtension({ - namespace: extension.namespace, - extension: extension.name, - targetPlatformVersions: targetPlatformVersions?.map(({ version, targetPlatform }) => ({ - version, - targetPlatform - })) - }); - await refetch(); - }; - return ( { placeholder='Namespace' error={namespaceFieldError} key='nsi' - onChange={handleNamespaceChange} + onChange={setNamespaceValue} value={namespaceValue} hideIconButton={true} autoFocus={true} @@ -108,7 +91,7 @@ export const ExtensionAdmin: FunctionComponent = () => { { , error ? {error} : '' ]} - listContainer={extension ? : ''} + listContainer={ + extension ? ( + + deleteExtension({ + namespace: extension.namespace, + extension: extension.name, + targetPlatformVersions: targets + }) + } + onVersionDeleted={refetch} + /> + ) : ( + '' + ) + } loading={loading} /> ); diff --git a/webui/src/pages/admin-dashboard/extension-remove-dialog.tsx b/webui/src/pages/admin-dashboard/extension-remove-dialog.tsx deleted file mode 100644 index fa2d6308c..000000000 --- a/webui/src/pages/admin-dashboard/extension-remove-dialog.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2020 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { FunctionComponent, useState, useContext } from 'react'; -import { - Button, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, - Typography -} from '@mui/material'; -import { ButtonWithProgress } from '../../components/button-with-progress'; -import { Extension, TargetPlatformVersion } from '../../extension-registry-types'; -import { MainContext } from '../../context'; -import { getTargetPlatformDisplayName } from '../../utils'; - -export const ExtensionRemoveDialog: FunctionComponent = props => { - const { handleError } = useContext(MainContext); - - const [dialogOpen, setDialogOpen] = useState(false); - const [working, setWorking] = useState(false); - - const removeVersions = () => { - return props.targetPlatformVersions.length > 1; - }; - - const handleRemoveVersions = async () => { - try { - setWorking(true); - await props.onRemove(props.targetPlatformVersions); - setDialogOpen(false); - } catch (err) { - handleError(err); - } finally { - setWorking(false); - } - }; - - const buttonText = removeVersions() ? 'Remove Versions' : 'Remove Version'; - return ( - <> - - setDialogOpen(false)}> - - Remove {props.targetPlatformVersions.length} version{removeVersions() ? 's' : ''} of{' '} - {props.extension.name}? - - - - {props.targetPlatformVersions.map((targetPlatformVersion, key) => ( - - {targetPlatformVersion.version} ( - {getTargetPlatformDisplayName(targetPlatformVersion.targetPlatform)}) - - ))} - - - - - - Remove - - - - - ); -}; - -export interface ExtensionRemoveDialogProps { - targetPlatformVersions: TargetPlatformVersion[]; - extension: Extension; - onRemove: (targetPlatformVersions?: TargetPlatformVersion[]) => Promise; -} diff --git a/webui/src/pages/admin-dashboard/extension-version-container.tsx b/webui/src/pages/admin-dashboard/extension-version-container.tsx deleted file mode 100644 index f8c4845f7..000000000 --- a/webui/src/pages/admin-dashboard/extension-version-container.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2020 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { ChangeEvent, FunctionComponent, useState, useEffect } from 'react'; -import { Extension, TargetPlatformVersion, VERSION_ALIASES } from '../../extension-registry-types'; -import { Box, Grid, Typography, FormControl, FormGroup, FormControlLabel, Checkbox } from '@mui/material'; -import WarningIcon from '@mui/icons-material/Warning'; -import { ExtensionRemoveDialog } from './extension-remove-dialog'; -import { getTargetPlatformDisplayName } from '../../utils'; -import { useExtensionIcon } from './use-extension-admin'; - -export const ExtensionVersionContainer: FunctionComponent = props => { - const WILDCARD = '*'; - const { extension } = props; - - const getTargetPlatformVersions = () => { - const versionMap: TargetPlatformVersion[] = []; - versionMap.push({ targetPlatform: WILDCARD, version: WILDCARD, checked: false }); - if (extension.allTargetPlatformVersions != null) { - extension.allTargetPlatformVersions - .filter(i => VERSION_ALIASES.indexOf(i.version) < 0) - .forEach(i => { - const { version, targetPlatforms } = i; - versionMap.push({ targetPlatform: WILDCARD, version, checked: false }); - targetPlatforms.forEach(targetPlatform => - versionMap.push({ targetPlatform, version, checked: false }) - ); - }); - } - - return versionMap; - }; - - const [targetPlatformVersions, setTargetPlatformVersions] = useState(getTargetPlatformVersions()); - const { data: icon } = useExtensionIcon(props.extension); - - useEffect(() => { - setTargetPlatformVersions(getTargetPlatformVersions()); - }, [props.extension]); - - // Revoke the previous object URL when the icon changes or on unmount. - useEffect(() => { - return () => { - if (icon) { - URL.revokeObjectURL(icon); - } - }; - }, [icon]); - - const handleChange = (event: ChangeEvent) => { - const newTargetPlatformVersions: TargetPlatformVersion[] = []; - targetPlatformVersions.forEach(targetPlatformVersion => { - const equals = (change: string, current: string) => { - return change === WILDCARD || current === change; - }; - - const [changedTarget, changedVersion] = event.target.name.split('/'); - if ( - equals(changedVersion, targetPlatformVersion.version) && - equals(changedTarget, targetPlatformVersion.targetPlatform) - ) { - targetPlatformVersion.checked = event.target.checked; - } - - newTargetPlatformVersions.push(targetPlatformVersion); - }); - - const newVersionsMap = new Map(); - newTargetPlatformVersions.forEach(targetPlatformVersion => { - if (targetPlatformVersion.version !== WILDCARD && targetPlatformVersion.targetPlatform !== WILDCARD) { - const checked = newVersionsMap.get(targetPlatformVersion.version) ?? true; - newVersionsMap.set(targetPlatformVersion.version, checked && targetPlatformVersion.checked); - } - }); - - newVersionsMap.forEach((checked, version) => { - const targetVersion = newTargetPlatformVersions.find( - t => t.version === version && t.targetPlatform === WILDCARD - ); - targetVersion!.checked = checked; - }); - - const checkedCount = Array.from(newTargetPlatformVersions).filter(t => t.checked === true).length; - if (checkedCount < newTargetPlatformVersions.length) { - const allChecked = checkedCount === newTargetPlatformVersions.length - 1; - const allVersions = newTargetPlatformVersions.find( - t => t.version === WILDCARD && t.targetPlatform === WILDCARD - ); - allVersions!.checked = allChecked; - } - - setTargetPlatformVersions(newTargetPlatformVersions); - }; - - return ( - - - {icon ? ( - - - - ) : ( - '' - )} - - - - - {extension.displayName ?? extension.name} - - - {extension.deprecated && ( - - - - - -  This extension has been deprecated. - - - )} - - - {extension.namespace}.{extension.name} - - - - - {extension.description} - - - - - - - - - - - {targetPlatformVersions.map((targetPlatformVersion, index) => { - let label: string; - let indent: number; - if ( - targetPlatformVersion.version === WILDCARD && - targetPlatformVersion.targetPlatform === WILDCARD - ) { - label = 'All Versions'; - indent = 0; - } else if (targetPlatformVersion.targetPlatform === WILDCARD) { - label = targetPlatformVersion.version; - indent = 4; - } else { - label = getTargetPlatformDisplayName(targetPlatformVersion.targetPlatform); - indent = 8; - } - - const name = `${targetPlatformVersion.targetPlatform}/${targetPlatformVersion.version}`; - return ( - - } - label={label} - /> - ); - })} - - - - - - - - value.checked && value.version != WILDCARD && value.targetPlatform != WILDCARD - )} - /> - - - - ); -}; - -export interface ExtensionVersionContainerProps { - extension: Extension; - onRemove: (targetPlatformVersions?: TargetPlatformVersion[]) => Promise; -} diff --git a/webui/src/pages/admin-dashboard/use-extension-admin.ts b/webui/src/pages/admin-dashboard/use-extension-admin.ts index 0702c10c7..61d09be91 100644 --- a/webui/src/pages/admin-dashboard/use-extension-admin.ts +++ b/webui/src/pages/admin-dashboard/use-extension-admin.ts @@ -14,7 +14,7 @@ import { useContext } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { MainContext } from '../../context'; -import { Extension, isError } from '../../extension-registry-types'; +import { isError } from '../../extension-registry-types'; import { controllerFromSignal } from '../../query-client'; interface ExtensionTarget { @@ -65,22 +65,3 @@ export const useDeleteExtension = () => { mutationFn: (req: DeleteExtensionRequest) => service.admin.deleteExtensions(req) }); }; - -/** - * Loads an extension icon as an object URL. Caching is disabled so a fresh URL - * is created per mount and the consumer can revoke the previous one without - * risking reuse of a revoked URL from the cache. - */ -export const useExtensionIcon = (extension: Extension) => { - const { service } = useContext(MainContext); - return useQuery({ - queryKey: ['extension-icon', extension.namespace, extension.name, extension.version, extension.targetPlatform], - queryFn: async ({ signal }) => { - const icon = await service.getExtensionIcon(controllerFromSignal(signal), extension); - // useQuery forbids `undefined`; normalise the "no icon" case to null. - return icon ?? null; - }, - gcTime: 0, - staleTime: 0 - }); -}; diff --git a/webui/src/pages/extension-detail/extension-detail.tsx b/webui/src/pages/extension-detail/extension-detail.tsx index 848f79370..3349a79fc 100644 --- a/webui/src/pages/extension-detail/extension-detail.tsx +++ b/webui/src/pages/extension-detail/extension-detail.tsx @@ -32,6 +32,7 @@ import WarningIcon from '@mui/icons-material/Warning'; import { MainContext } from '../../context'; import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; +import { ExtensionIcon } from '../../components/extension/extension-icon'; import { HoverPopover } from '../../components/hover-popover'; import { Extension, UserData } from '../../extension-registry-types'; import { TextDivider } from '../../components/text-divider'; @@ -288,8 +289,7 @@ const ExtensionHeaderInfo: FunctionComponent<{ const ExtensionHeader: FunctionComponent<{ extension: Extension; - icon: string | undefined; -}> = ({ extension, icon }) => { +}> = ({ extension }) => { const theme = useTheme(); const { pageSettings } = useContext(MainContext); @@ -323,10 +323,8 @@ const ExtensionHeader: FunctionComponent<{ textAlign: { xs: 'center', md: 'start' }, alignItems: { xs: 'center', md: 'normal' } }}> - @@ -348,12 +346,7 @@ export const ExtensionDetail: FunctionComponent = () => { const activeTab = parseTab(version); // React Router v6 returns a possibly undefined type for params, but our route configuration guarantees these will be defined. - const { loading, error, extension, icon, reload } = useExtensionDetail( - namespace!, - name!, - target!, - effectiveVersion! - ); + const { loading, error, extension, reload } = useExtensionDetail(namespace!, name!, target!, effectiveVersion!); const navigateToVersion = useCallback( (selectedVersion: string) => { @@ -388,7 +381,7 @@ export const ExtensionDetail: FunctionComponent = () => { {extension && ( <> - + void; } @@ -36,15 +35,11 @@ export const useExtensionDetail = ( const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [extension, setExtension] = useState(); - const [icon, setIcon] = useState(); const [reloadKey, setReloadKey] = useState(0); useEffect(() => { return () => { abortController.current.abort(); - if (icon) { - URL.revokeObjectURL(icon); - } }; }, []); @@ -54,21 +49,13 @@ export const useExtensionDetail = ( const fetchExtension = async () => { setExtension(undefined); setError(undefined); - setIcon(undefined); const extensionUrl = service.getExtensionApiUrl({ namespace, name, target, version }); const response = await service.getExtensionDetail(abortController.current, extensionUrl); if (isError(response)) throw response; - const ext = response as Extension; - const iconUrl = await service.getExtensionIcon(abortController.current, ext); - if (abortController.current.signal.aborted) { - if (iconUrl) { - URL.revokeObjectURL(iconUrl); - } - } else { - setExtension(ext); - setIcon(iconUrl); + if (!abortController.current.signal.aborted) { + setExtension(response as Extension); } }; @@ -97,5 +84,5 @@ export const useExtensionDetail = ( // This function updates the reloadKey state to trigger a refetch of the extension details. const reload = useCallback(() => setReloadKey(k => k + 1), []); - return { loading, extension, error, icon, reload }; + return { loading, extension, error, reload }; }; diff --git a/webui/src/pages/extension-list/extension-list-item.tsx b/webui/src/pages/extension-list/extension-list-item.tsx index 88e67c8d8..435ee34c3 100644 --- a/webui/src/pages/extension-list/extension-list-item.tsx +++ b/webui/src/pages/extension-list/extension-list-item.tsx @@ -8,47 +8,17 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, useContext, useState, useEffect, useRef } from 'react'; +import { FunctionComponent } from 'react'; import { Link as RouteLink } from 'react-router-dom'; import { Paper, Typography, Box, Grid, Fade } from '@mui/material'; import SaveAltIcon from '@mui/icons-material/SaveAlt'; -import { MainContext } from '../../context'; import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes'; import { SearchEntry } from '../../extension-registry-types'; +import { ExtensionIcon } from '../../components/extension/extension-icon'; import { ExtensionRatingStars } from '../extension-detail/extension-rating-stars'; import { createRoute } from '../../utils'; export const ExtensionListItem: FunctionComponent = props => { - const [icon, setIcon] = useState(); - const context = useContext(MainContext); - const abortController = useRef(new AbortController()); - - useEffect(() => { - updateChanges(); - return () => { - abortController.current.abort(); - if (icon) { - URL.revokeObjectURL(icon); - } - }; - }, []); - - useEffect(() => { - updateChanges(); - }, [props.extension.namespace, props.extension.name, props.extension.version]); - - const updateChanges = async (): Promise => { - if (icon) { - URL.revokeObjectURL(icon); - } - try { - const icon = await context.service.getExtensionIcon(abortController.current, props.extension); - setIcon(icon); - } catch (err) { - context.handleError(err); - } - }; - const { extension, filterSize, idx } = props; const route = createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name]); const numberFormat = new Intl.NumberFormat(undefined, { notation: 'compact', compactDisplay: 'short' } as any); @@ -77,12 +47,7 @@ export const ExtensionListItem: FunctionComponent = prop filter: extension.deprecated ? 'grayscale(100%)' : undefined }}> - + diff --git a/webui/src/pages/user/use-user-extension.ts b/webui/src/pages/user/use-user-extension.ts new file mode 100644 index 000000000..c69655238 --- /dev/null +++ b/webui/src/pages/user/use-user-extension.ts @@ -0,0 +1,58 @@ +/****************************************************************************** + * 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 { useContext } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { MainContext } from '../../context'; +import { isError } from '../../extension-registry-types'; +import { controllerFromSignal } from '../../query-client'; + +interface UserExtensionTarget { + namespace: string; + extension: string; +} + +interface DeleteUserExtensionRequest { + namespace: string; + extension: string; + targetPlatformVersions?: object[]; +} + +/** + * Loads one of the current user's extensions for the extension settings page. + */ +export const useUserExtension = (target: UserExtensionTarget) => { + const { service } = useContext(MainContext); + return useQuery({ + queryKey: ['user', 'extension', target.namespace, target.extension], + queryFn: async ({ signal }) => { + const result = await service.getExtension(controllerFromSignal(signal), target.namespace, target.extension); + if (isError(result)) { + throw result; + } + return result; + } + }); +}; + +/** + * Deletes extension versions. Mirrors the previous behaviour of not throwing on + * an error result; thrown (network/server) errors reject so the caller's catch + * path runs. + */ +export const useDeleteUserExtensionVersions = () => { + const { service } = useContext(MainContext); + return useMutation({ + mutationFn: (req: DeleteUserExtensionRequest) => service.deleteExtensions(req) + }); +}; diff --git a/webui/src/pages/user/user-namespace-extension-list-item.tsx b/webui/src/pages/user/user-namespace-extension-list-item.tsx index 4520abd2d..79ab92466 100644 --- a/webui/src/pages/user/user-namespace-extension-list-item.tsx +++ b/webui/src/pages/user/user-namespace-extension-list-item.tsx @@ -8,16 +8,14 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import type { MouseEvent, ReactNode } from 'react'; -import { useContext, FunctionComponent, useState, useEffect, useRef } from 'react'; +import type { ReactNode } from 'react'; +import { FunctionComponent } from 'react'; +import { Link as RouteLink } from 'react-router-dom'; +import { Paper, Typography, Box, styled } from '@mui/material'; import { Extension } from '../../extension-registry-types'; -import { Paper, Typography, Box, styled, IconButton } from '@mui/material'; -import { Link as RouteLink, useNavigate } from 'react-router-dom'; -import { MainContext } from '../../context'; import { createRoute } from '../../utils'; +import { ExtensionIcon } from '../../components/extension/extension-icon'; import { Timestamp } from '../../components/timestamp'; -import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes'; -import DeleteIcon from '@mui/icons-material/Delete'; import { UserSettingsRoutes } from './user-settings-routes'; const getOpacity = (extension: Extension) => { @@ -43,28 +41,9 @@ const Paragraph = styled(Box)({ }); export const UserNamespaceExtensionListItem: FunctionComponent = props => { - const { pageSettings, service } = useContext(MainContext); - const [icon, setIcon] = useState(undefined); const { extension } = props; - const route = (extension && createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name])) || ''; - const deleteRoute = - (extension && createRoute([UserSettingsRoutes.EXTENSIONS, extension.namespace, extension.name, 'delete'])) || - ''; + const route = createRoute([UserSettingsRoutes.EXTENSIONS, extension.namespace, extension.name]); const inactive = extension.active === false; - const abortController = useRef(new AbortController()); - const navigate = useNavigate(); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); - useEffect(() => { - if (icon) { - URL.revokeObjectURL(icon); - } - - service.getExtensionIcon(abortController.current, extension).then(setIcon); - }, [extension]); const renderStatus = (): ReactNode => { if (extension.reviewStatus === 'under_review') { @@ -112,11 +91,6 @@ export const UserNamespaceExtensionListItem: FunctionComponent { - e.preventDefault(); - navigate(deleteRoute); - }; - return extension ? ( - {extension.displayName ?? extension.name} - {props.canDelete && deleteRoute && ( - - - - )} Version: diff --git a/webui/src/pages/user/user-setting-tabs.tsx b/webui/src/pages/user/user-setting-tabs.tsx index f2a0d9550..cad716d44 100644 --- a/webui/src/pages/user/user-setting-tabs.tsx +++ b/webui/src/pages/user/user-setting-tabs.tsx @@ -8,12 +8,20 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { ChangeEvent, ReactElement } from 'react'; +import { ReactElement } from 'react'; import { Tabs, Tab, useTheme, useMediaQuery } from '@mui/material'; import { useNavigate, useParams } from 'react-router-dom'; import { createRoute } from '../../utils'; import { UserSettingsRoutes } from './user-settings-routes'; +const TABS = [ + { value: 'profile', label: 'Profile' }, + { value: 'tokens', label: 'Access Tokens' }, + { value: 'namespaces', label: 'Namespaces' }, + { value: 'extensions', label: 'Extensions' }, + { value: 'customers', label: 'Rate Limiting' } +]; + export const UserSettingTabs = (): ReactElement => { const theme = useTheme(); const isATablet = useMediaQuery(theme.breakpoints.down('md')); @@ -22,10 +30,6 @@ export const UserSettingTabs = (): ReactElement => { const navigate = useNavigate(); - const handleChange = (event: ChangeEvent, newTab: string) => { - navigate(generateRoute(newTab)); - }; - const generateRoute = (tab: string) => { return createRoute([UserSettingsRoutes.ROOT, tab]); }; @@ -33,15 +37,15 @@ export const UserSettingTabs = (): ReactElement => { return ( - - - - - + {TABS.map(({ value, label }) => ( + // MUI's Tabs only fires `onChange` when the clicked tab differs from the + // currently selected one, so a per-Tab `onClick` is used instead to also + // navigate when the already-active tab is clicked. + navigate(generateRoute(value))} /> + ))} ); }; diff --git a/webui/src/pages/user/user-settings-delete-extension.tsx b/webui/src/pages/user/user-settings-delete-extension.tsx deleted file mode 100644 index 5baa08d79..000000000 --- a/webui/src/pages/user/user-settings-delete-extension.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** ****************************************************************************** - * Copyright (c) 2025 Precies. Software OU and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - * ****************************************************************************** */ - -import { FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; -import { Box } from '@mui/material'; -import { MainContext } from '../../context'; -import { isError, Extension, TargetPlatformVersion } from '../../extension-registry-types'; -import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; -import { ExtensionVersionContainer } from '../admin-dashboard/extension-version-container'; -import { useNavigate } from 'react-router'; -import { UserSettingsRoutes } from './user-settings-routes'; - -export const UserSettingsDeleteExtension: FunctionComponent = props => { - const navigate = useNavigate(); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); - - useEffect(() => { - findExtension(); - }, [props.namespace, props.extension]); - - const [loading, setLoading] = useState(false); - - const { service, handleError } = useContext(MainContext); - const [extension, setExtension] = useState(undefined); - const findExtension = async () => { - try { - setLoading(true); - const extensionDetail = await service.getExtension( - abortController.current, - props.namespace, - props.extension - ); - if (isError(extensionDetail)) { - throw extensionDetail; - } - setExtension(extensionDetail); - setLoading(false); - } catch (err) { - setLoading(false); - setExtension(undefined); - if (err && err.status === 404) { - navigate(UserSettingsRoutes.EXTENSIONS); - } else { - handleError(err); - } - } - }; - - const onRemove = async (targetPlatformVersions?: TargetPlatformVersion[]) => { - if (extension == null) { - return; - } - - await service.deleteExtensions(abortController.current, { - namespace: extension.namespace, - extension: extension.name, - targetPlatformVersions: targetPlatformVersions?.map(({ version, targetPlatform }) => ({ - version, - targetPlatform - })) - }); - await findExtension(); - }; - - return ( - - - {extension ? : ''} - - ); -}; - -export interface UserSettingsDeleteExtensionProps { - namespace: string; - extension: string; -} diff --git a/webui/src/pages/user/user-settings-extension.tsx b/webui/src/pages/user/user-settings-extension.tsx new file mode 100644 index 000000000..350825b3a --- /dev/null +++ b/webui/src/pages/user/user-settings-extension.tsx @@ -0,0 +1,71 @@ +/****************************************************************************** + * 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 } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { MainContext } from '../../context'; +import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; +import { ExtensionDetailView } from '../../components/extension/extension-detail-view'; +import { UserSettingsRoutes } from './user-settings-routes'; +import { useDeleteUserExtensionVersions, useUserExtension } from './use-user-extension'; + +export const UserSettingsExtensionSettings: FunctionComponent = props => { + const { handleError } = useContext(MainContext); + const navigate = useNavigate(); + + const { + data: extension, + isFetching: loading, + error, + refetch + } = useUserExtension({ namespace: props.namespace, extension: props.extension }); + const { mutateAsync: deleteVersions } = useDeleteUserExtensionVersions(); + + useEffect(() => { + if (!error) { + return; + } + if ((error as { status?: number }).status === 404) { + navigate(UserSettingsRoutes.EXTENSIONS); + } else { + handleError(error); + } + }, [error, navigate, handleError]); + + if (loading) { + return ; + } + + if (!extension) { + return null; + } + + return ( + + deleteVersions({ + namespace: extension.namespace, + extension: extension.name, + targetPlatformVersions: targets + }) + } + onVersionDeleted={refetch} + /> + ); +}; + +export interface UserSettingsExtensionSettingsProps { + namespace: string; + extension: string; +} diff --git a/webui/src/pages/user/user-settings-routes.ts b/webui/src/pages/user/user-settings-routes.ts index 15e4076d7..38bd2c176 100644 --- a/webui/src/pages/user/user-settings-routes.ts +++ b/webui/src/pages/user/user-settings-routes.ts @@ -20,6 +20,6 @@ export namespace UserSettingsRoutes { export const TOKENS = createRoute([ROOT, 'tokens']); export const NAMESPACES = createRoute([ROOT, 'namespaces']); export const EXTENSIONS = createRoute([ROOT, 'extensions']); - export const DELETE_EXTENSION = createRoute([ROOT, 'extensions', ':namespace', ':extension', 'delete']); + export const EXTENSION_SETTINGS = createRoute([ROOT, 'extensions', ':namespace', ':extension']); export const CUSTOMERS = createRoute([ROOT, 'customers']); } diff --git a/webui/src/pages/user/user-settings.tsx b/webui/src/pages/user/user-settings.tsx index 25b1834f2..f9ef62987 100644 --- a/webui/src/pages/user/user-settings.tsx +++ b/webui/src/pages/user/user-settings.tsx @@ -22,7 +22,7 @@ import { UserSettingsCustomers } from './user-settings-customers'; import { MainContext } from '../../context'; import { UserData } from '../../extension-registry-types'; import { LoginComponent } from '../../default/login'; -import { UserSettingsDeleteExtension } from './user-settings-delete-extension'; +import { UserSettingsExtensionSettings } from './user-settings-extension'; export const UserSettings: FunctionComponent = props => { const { pageSettings, user, loginProviders } = useContext(MainContext); @@ -30,7 +30,7 @@ export const UserSettings: FunctionComponent = props => { const renderTab = (user: UserData, tab?: string, namespace?: string, extension?: string): ReactNode => { if (tab == null && namespace != null && extension != null) { - return ; + return ; } switch (tab) {