Skip to content

Commit 11a037c

Browse files
authored
feat: add extension detail page for user-settings and admin dashboard (#1939)
* add an extension detail page for managing an extension and its versions, add active field to indicate if a version for a target platform is active, remove obsolete delete routes as the delete is no on the detail page * add changelog entry, formatting * remove accidentally committed code * remove reference to admin context * refactor loading an extension icons into a separate component * use tanstack to load user extension data, fix import for extension icon component * ensure that clicking on the tab always triggers a navigation event * make mutation operation non retriable * use nonretriable requests for getExtension as well * fix import * restore abortController for get request
1 parent c32418c commit 11a037c

32 files changed

Lines changed: 922 additions & 598 deletions

server/src/main/java/org/eclipse/openvsx/UserAPI.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020

2121
import java.util.LinkedHashMap;
2222
import java.util.List;
23-
import java.util.Optional;
2423
import java.util.concurrent.TimeUnit;
2524

2625
import org.eclipse.openvsx.accesstoken.AccessTokenService;
@@ -241,7 +240,8 @@ public List<ExtensionJson> getOwnExtensions() {
241240
}
242241

243242
var extVersions = repositories.findLatestVersions(user);
244-
var types = new String[]{ DOWNLOAD, MANIFEST, ICON, README, LICENSE, CHANGELOG, VSIXMANIFEST };
243+
244+
var types = new String[] { DOWNLOAD, MANIFEST, ICON, README, LICENSE, CHANGELOG, VSIXMANIFEST };
245245
var fileUrls = storageUtil.getFileUrls(extVersions, UrlUtil.getBaseUrl(), types);
246246
return extVersions.stream()
247247
.map(latest -> {
@@ -260,7 +260,7 @@ public List<ExtensionJson> getOwnExtensions() {
260260

261261
/**
262262
* Add review/scan status information to the extension JSON.
263-
*
263+
* <p>
264264
* This shows users the current state of their extension in simple terms:
265265
* - "published" - Extension is active and publicly available
266266
* - "under_review" - Extension is being reviewed (validation, scanning, etc.)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/******************************************************************************
2+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information regarding copyright ownership.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* https://www.eclipse.org/legal/epl-2.0.
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*****************************************************************************/
13+
package org.eclipse.openvsx.json;
14+
15+
import io.swagger.v3.oas.annotations.media.Schema;
16+
17+
import static org.eclipse.openvsx.util.TargetPlatform.*;
18+
19+
/**
20+
*
21+
* @param targetPlatform Name of the target platform
22+
* @param active Whether this target platform version is active
23+
*/
24+
@Schema(
25+
name = "TargetPlatformActive",
26+
description = "Target platform of an extension version and whether it is active"
27+
)
28+
public record TargetPlatformActiveJson(
29+
@Schema(description = "Name of the target platform", allowableValues = {
30+
NAME_WIN32_X64, NAME_WIN32_IA32, NAME_WIN32_ARM64,
31+
NAME_LINUX_X64, NAME_LINUX_ARM64, NAME_LINUX_ARMHF,
32+
NAME_ALPINE_X64, NAME_ALPINE_ARM64,
33+
NAME_DARWIN_X64, NAME_DARWIN_ARM64,
34+
NAME_WEB, NAME_UNIVERSAL
35+
})
36+
String targetPlatform,
37+
@Schema(description = "Whether this extension version for this target platform is active")
38+
boolean active
39+
) {}

server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@
99
* ****************************************************************************** */
1010
package org.eclipse.openvsx.json;
1111

12-
public record VersionTargetPlatformsJson(String version, String[] targetPlatforms) {}
12+
import java.util.List;
13+
14+
public record VersionTargetPlatformsJson(String version, List<TargetPlatformActiveJson> targetPlatforms) {}

server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.apache.commons.lang3.StringUtils;
1313
import org.eclipse.openvsx.entities.*;
1414
import org.eclipse.openvsx.json.QueryRequest;
15+
import org.eclipse.openvsx.json.TargetPlatformActiveJson;
1516
import org.eclipse.openvsx.json.TargetPlatformVersionJson;
1617
import org.eclipse.openvsx.json.VersionTargetPlatformsJson;
1718
import org.eclipse.openvsx.util.TargetPlatform;
@@ -545,14 +546,20 @@ public List<VersionTargetPlatformsJson> findTargetPlatformsGroupedByVersion(Exte
545546
EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(),
546547
EXTENSION_VERSION.TARGET_PLATFORM.asc()
547548
);
549+
var targetPlatformsActive = DSL.arrayAgg(EXTENSION_VERSION.ACTIVE)
550+
.orderBy(
551+
EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(),
552+
EXTENSION_VERSION.TARGET_PLATFORM.asc()
553+
);
548554

549555
return dsl.select(
550556
EXTENSION_VERSION.SEMVER_MAJOR,
551557
EXTENSION_VERSION.SEMVER_MINOR,
552558
EXTENSION_VERSION.SEMVER_PATCH,
553559
EXTENSION_VERSION.SEMVER_IS_PRE_RELEASE,
554560
EXTENSION_VERSION.VERSION,
555-
targetPlatforms
561+
targetPlatforms,
562+
targetPlatformsActive
556563
)
557564
.from(EXTENSION_VERSION)
558565
.where(EXTENSION_VERSION.EXTENSION_ID.eq(extension.getId()))
@@ -571,9 +578,10 @@ public List<VersionTargetPlatformsJson> findTargetPlatformsGroupedByVersion(Exte
571578
EXTENSION_VERSION.VERSION.asc()
572579
)
573580
.fetch()
574-
.map(row -> new VersionTargetPlatformsJson(
581+
.map(row -> toVersionTargetPlatformsJson(
575582
row.get(EXTENSION_VERSION.VERSION),
576-
row.get(targetPlatforms)
583+
row.get(targetPlatforms),
584+
row.get(targetPlatformsActive)
577585
));
578586
}
579587

@@ -583,14 +591,20 @@ public List<VersionTargetPlatformsJson> findTargetPlatformsGroupedByVersion(Exte
583591
EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(),
584592
EXTENSION_VERSION.TARGET_PLATFORM.asc()
585593
);
594+
var targetPlatformsActive = DSL.arrayAgg(EXTENSION_VERSION.ACTIVE)
595+
.orderBy(
596+
EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(),
597+
EXTENSION_VERSION.TARGET_PLATFORM.asc()
598+
);
586599

587600
return dsl.select(
588601
EXTENSION_VERSION.SEMVER_MAJOR,
589602
EXTENSION_VERSION.SEMVER_MINOR,
590603
EXTENSION_VERSION.SEMVER_PATCH,
591604
EXTENSION_VERSION.SEMVER_IS_PRE_RELEASE,
592605
EXTENSION_VERSION.VERSION,
593-
targetPlatforms
606+
targetPlatforms,
607+
targetPlatformsActive
594608
)
595609
.from(EXTENSION_VERSION)
596610
.join(PERSONAL_ACCESS_TOKEN).on(PERSONAL_ACCESS_TOKEN.ID.eq(EXTENSION_VERSION.PUBLISHED_WITH_ID))
@@ -611,12 +625,22 @@ public List<VersionTargetPlatformsJson> findTargetPlatformsGroupedByVersion(Exte
611625
EXTENSION_VERSION.VERSION.asc()
612626
)
613627
.fetch()
614-
.map(row -> new VersionTargetPlatformsJson(
628+
.map(row -> toVersionTargetPlatformsJson(
615629
row.get(EXTENSION_VERSION.VERSION),
616-
row.get(targetPlatforms)
630+
row.get(targetPlatforms),
631+
row.get(targetPlatformsActive)
617632
));
618633
}
619634

635+
private VersionTargetPlatformsJson toVersionTargetPlatformsJson(String version, String[] targetPlatforms, Boolean[] active) {
636+
var platforms = new ArrayList<TargetPlatformActiveJson>(targetPlatforms.length);
637+
for (int i = 0; i < targetPlatforms.length; i++) {
638+
platforms.add(new TargetPlatformActiveJson(targetPlatforms[i], active[i]));
639+
}
640+
641+
return new VersionTargetPlatformsJson(version, platforms);
642+
}
643+
620644
public List<ExtensionVersion> findVersionsForUrls(Extension extension, String targetPlatform, String version) {
621645
var query = dsl.selectQuery();
622646
query.addSelect(

webui/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This change log covers only the frontend library (webui) of Open VSX.
77
### Added
88

99
- Support searching users and managing their roles in the admin dashboard ([#1847](https://github.com/eclipse-openvsx/openvsx/pull/1847))
10+
- Added an extension details page to admin dashboard and user settings ([#1939](https://github.com/eclipse-openvsx/openvsx/pull/1939))
1011

1112
### Changed
1213

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/******************************************************************************
2+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information regarding copyright ownership.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* https://www.eclipse.org/legal/epl-2.0.
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*****************************************************************************/
13+
14+
import { FunctionComponent, useContext, useState } from 'react';
15+
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material';
16+
import { ButtonWithProgress } from '../button-with-progress';
17+
import { MainContext } from '../../context';
18+
import { Extension, VersionTargetPlatforms } from '../../extension-registry-types';
19+
import { VersionDeleteTarget } from './extension-version-delete-dialog';
20+
21+
export const DeleteAllVersionsDialog: FunctionComponent<DeleteAllVersionsDialogProps> = props => {
22+
const { handleError } = useContext(MainContext);
23+
const [working, setWorking] = useState(false);
24+
25+
const handleRemove = async () => {
26+
try {
27+
setWorking(true);
28+
const targets: VersionDeleteTarget[] = props.versions.flatMap(v =>
29+
v.targetPlatforms.map(({ targetPlatform }) => ({ version: v.version, targetPlatform }))
30+
);
31+
await props.onRemove(targets);
32+
props.onDeleted();
33+
props.onClose();
34+
} catch (err) {
35+
handleError(err);
36+
} finally {
37+
setWorking(false);
38+
}
39+
};
40+
41+
return (
42+
<Dialog open={props.open} onClose={props.onClose} maxWidth='xs' fullWidth>
43+
<DialogTitle>Delete all versions of {props.extension.displayName ?? props.extension.name}?</DialogTitle>
44+
<DialogContent>
45+
<DialogContentText>
46+
This will permanently remove {props.versions.length} version
47+
{props.versions.length === 1 ? '' : 's'} of this extension across all target platforms. This action
48+
cannot be undone.
49+
</DialogContentText>
50+
</DialogContent>
51+
<DialogActions>
52+
<Button variant='contained' onClick={props.onClose}>
53+
Cancel
54+
</Button>
55+
<ButtonWithProgress sx={{ ml: 1 }} color='error' working={working} onClick={handleRemove}>
56+
Delete All Versions
57+
</ButtonWithProgress>
58+
</DialogActions>
59+
</Dialog>
60+
);
61+
};
62+
63+
export interface DeleteAllVersionsDialogProps {
64+
open: boolean;
65+
onClose: () => void;
66+
extension: Extension;
67+
versions: VersionTargetPlatforms[];
68+
onRemove: (targets: VersionDeleteTarget[]) => Promise<unknown>;
69+
onDeleted: () => void;
70+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/******************************************************************************
2+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information regarding copyright ownership.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* https://www.eclipse.org/legal/epl-2.0.
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*****************************************************************************/
13+
14+
import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
15+
import { Box, Button, Divider, Stack, Typography } from '@mui/material';
16+
import { Link as RouteLink } from 'react-router-dom';
17+
import { Extension, VERSION_ALIASES, VersionTargetPlatforms } from '../../extension-registry-types';
18+
import { ExtensionHeader } from './extension-header';
19+
import { ExtensionStatusChips } from './extension-status-chips';
20+
import { ExtensionVersionTable } from './extension-version-table';
21+
import { DeleteVersionDialog, VersionDeleteTarget } from './extension-version-delete-dialog';
22+
import { DeleteAllVersionsDialog } from './extension-delete-all-versions-dialog';
23+
import { ExtensionDetailRoutes } from '../../pages/extension-detail/extension-detail-routes';
24+
import { createRoute } from '../../utils';
25+
26+
export const ExtensionDetailView: FunctionComponent<ExtensionDetailViewProps> = props => {
27+
const { extension, actions, onRemoveVersion, onVersionDeleted } = props;
28+
29+
const [page, setPage] = useState(0);
30+
const [deleteDialogVersion, setDeleteDialogVersion] = useState<VersionTargetPlatforms | null>(null);
31+
const [deleteAllOpen, setDeleteAllOpen] = useState(false);
32+
33+
useEffect(() => {
34+
setPage(0);
35+
}, [extension]);
36+
37+
const publicRoute = createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name]);
38+
const allVersions = (extension.allTargetPlatformVersions ?? []).filter(v => !VERSION_ALIASES.includes(v.version));
39+
40+
return (
41+
<Box>
42+
<ExtensionHeader extension={extension} />
43+
{extension.description && (
44+
<Typography variant='body1' mb={2}>
45+
{extension.description}
46+
</Typography>
47+
)}
48+
<ExtensionStatusChips extension={extension} />
49+
<Divider sx={{ my: 2 }} />
50+
<Stack direction='row' spacing={2} mb={3}>
51+
<Button variant='outlined' component={RouteLink} to={publicRoute}>
52+
View in Marketplace
53+
</Button>
54+
<Button
55+
variant='outlined'
56+
color='error'
57+
onClick={() => setDeleteAllOpen(true)}
58+
disabled={allVersions.length === 0}>
59+
Delete All Versions
60+
</Button>
61+
{actions}
62+
</Stack>
63+
<Typography variant='h6' gutterBottom>
64+
Versions
65+
</Typography>
66+
<ExtensionVersionTable
67+
versions={allVersions}
68+
page={page}
69+
onPageChange={setPage}
70+
onDeleteVersion={setDeleteDialogVersion}
71+
/>
72+
{deleteDialogVersion && (
73+
<DeleteVersionDialog
74+
open={true}
75+
onClose={() => setDeleteDialogVersion(null)}
76+
extension={extension}
77+
version={deleteDialogVersion}
78+
onRemove={onRemoveVersion}
79+
onDeleted={onVersionDeleted}
80+
/>
81+
)}
82+
{deleteAllOpen && (
83+
<DeleteAllVersionsDialog
84+
open={true}
85+
onClose={() => setDeleteAllOpen(false)}
86+
extension={extension}
87+
versions={allVersions}
88+
onRemove={onRemoveVersion}
89+
onDeleted={onVersionDeleted}
90+
/>
91+
)}
92+
</Box>
93+
);
94+
};
95+
96+
export interface ExtensionDetailViewProps {
97+
extension: Extension;
98+
actions?: ReactNode;
99+
onRemoveVersion: (targets: VersionDeleteTarget[]) => Promise<unknown>;
100+
onVersionDeleted: () => void;
101+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/******************************************************************************
2+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information regarding copyright ownership.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* https://www.eclipse.org/legal/epl-2.0.
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*****************************************************************************/
13+
14+
import { FunctionComponent } from 'react';
15+
import { Box, Typography } from '@mui/material';
16+
import { Extension } from '../../extension-registry-types';
17+
import { ExtensionIcon } from './extension-icon';
18+
19+
export const ExtensionHeader: FunctionComponent<ExtensionHeaderProps> = ({ extension }) => (
20+
<Box display='flex' alignItems='center' gap={2} mb={2}>
21+
<ExtensionIcon extension={extension} sx={{ width: '4rem', height: '4rem', objectFit: 'contain' }} />
22+
<Box>
23+
<Typography variant='h5'>{extension.displayName ?? extension.name}</Typography>
24+
<Typography variant='body2' color='text.secondary'>
25+
{extension.namespace}.{extension.name} &middot; v{extension.version}
26+
</Typography>
27+
</Box>
28+
</Box>
29+
);
30+
31+
export interface ExtensionHeaderProps {
32+
extension: Extension;
33+
}

0 commit comments

Comments
 (0)