From bdc14f987e0d8c8e2d59adefc19dbe5766e7202b Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Sat, 5 Sep 2026 11:13:56 +0200 Subject: [PATCH] feat: expose the extension package size in the API and web UI file_resource.size has been recorded since V1_73, populated on publish and backfilled for older rows, but nothing ever read it back out: it reaches no API response and no page. Issue #367 asks to display it. Add ExtensionJson.downloadSize - the size in bytes of the file behind files.download - and a Size entry in the web UI's More Info box beside the unique identifier, where #367 suggested it belongs. The size is read from the download resource rather than summed over the version's files: readme, icon, changelog and license are rows of their own holding content that is already inside the package, so a sum counts it twice. It is null, and omitted from the JSON, for a package published before the column existed that the backfill has not reached - a client cannot tell a zero from an unknown, so it gets neither. Note this is the download size. The install size the issue asks about is larger by whatever the package's compression ratio happens to be, and is not something the registry stores; it would need its own column and a job that re-reads every existing .vsix. The detail endpoint now takes StorageUtilService.getFiles instead of getFileUrls so the size comes from the same query as the URLs rather than a second one, and builds its file map with the toFilesJson the other two builders already share - which also drops the duplicated public-key handling it had grown. Co-Authored-By: Claude Opus 5 (1M context) --- .../eclipse/openvsx/LocalRegistryService.java | 34 ++++++++--- .../eclipse/openvsx/json/ExtensionJson.java | 19 ++++++ .../FileResourceJooqRepository.java | 19 +++++- .../openvsx/storage/StorageUtilService.java | 44 ++++++++++---- .../openvsx/LocalRegistryServiceTest.java | 2 +- .../org/eclipse/openvsx/RegistryAPITest.java | 29 +++++++++ .../java/org/eclipse/openvsx/UserAPITest.java | 24 ++------ webui/src/extension-registry-types.ts | 6 ++ .../extension-detail-overview.tsx | 18 +++++- .../extension-detail-overview.spec.tsx | 60 +++++++++++++++++++ 10 files changed, 213 insertions(+), 42 deletions(-) create mode 100644 webui/test/unit/pages/extension-detail/extension-detail-overview.spec.tsx diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 3a2b5ee5b..78d545c99 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -1101,14 +1101,16 @@ public ExtensionJson toExtensionVersionJson( } json.setAllVersions(allVersionsJson); - var fileUrls = storageUtil.getFileUrls( - List.of(extVersion), - serverUrl, - withFileTypes(DOWNLOAD, MANIFEST, ICON, README, LICENSE, CHANGELOG, VSIXMANIFEST)); - json.setFiles(fileUrls.get(extVersion.getId())); - if (json.getFiles().containsKey(DOWNLOAD_SIG)) { - json.getFiles().put(PUBLIC_KEY, UrlUtil.getPublicKeyUrl(extVersion)); - } + // getFiles rather than getFileUrls: the package size is read from the same rows as the URLs, so + // this path stays one query, and toFilesJson is the same mapping the two builders below already + // use - including the public key it adds alongside a signature. + var resources = storageUtil + .getFiles( + List.of(extVersion), + withFileTypes(DOWNLOAD, MANIFEST, ICON, README, LICENSE, CHANGELOG, VSIXMANIFEST)) + .get(extVersion.getId()); + json.setFiles(toFilesJson(extVersion, resources, UrlUtil.createApiFileBaseUrl(serverUrl, extVersion))); + json.setDownloadSize(downloadSize(resources)); if (json.getDependencies() != null) { for (var ref : json.getDependencies()) { ref.setUrl(createApiUrl(serverUrl, "api", ref.getNamespace(), ref.getExtension())); @@ -1189,6 +1191,7 @@ public ExtensionJson toExtensionVersionJson( } json.setFiles(files); + json.setDownloadSize(downloadSize(resources)); if (json.getDependencies() != null) { for (var ref : json.getDependencies()) { ref.setUrl(createApiUrl(serverUrl, "api", ref.getNamespace(), ref.getExtension())); @@ -1238,6 +1241,7 @@ public ExtensionJson toExtensionVersionJsonV2( json.getTargetPlatform(), json.getVersion()); json.setFiles(toFilesJson(extVersion, resources, fileBaseUrl)); + json.setDownloadSize(downloadSize(resources)); setExtensionReferenceUrls(json.getDependencies(), serverUrl); setExtensionReferenceUrls(json.getBundledExtensions(), serverUrl); return json; @@ -1297,6 +1301,20 @@ private Map toAllVersionsJson( return allVersionsJson; } + /** + * The size of the extension package itself, read from the {@code download} resource rather than summed + * over the version's files: readme, icon, changelog and license are stored as rows of their own but + * hold content that is already inside the package, so a sum would count it twice. Null where the + * package predates the size column and has not been backfilled yet. + */ + private Long downloadSize(List resources) { + return resources.stream() + .filter(resource -> DOWNLOAD.equals(resource.getType())) + .findFirst() + .map(FileResource::getSize) + .orElse(null); + } + private Map toFilesJson( ExtensionVersion extVersion, List resources, diff --git a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java index 0c7638bef..046d80bb6 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java @@ -48,6 +48,15 @@ public static ExtensionJson error(String message) { ) private Map files; + @Schema( + description = "Size in bytes of the extension package - the file behind the 'download' entry of " + + "'files' - for this version and target platform. Null where the size is not known, which " + + "is the case for packages published before the registry started recording it and not yet " + + "backfilled. This is the download size; the installed extension is larger, by whatever " + + "the package's compression ratio happens to be." + ) + private Long downloadSize; + @Schema(description = "Name of the extension") @NotNull private String name; @@ -259,6 +268,14 @@ public void setFiles(Map files) { this.files = files; } + public Long getDownloadSize() { + return downloadSize; + } + + public void setDownloadSize(Long downloadSize) { + this.downloadSize = downloadSize; + } + public String getName() { return name; } @@ -647,6 +664,7 @@ public boolean equals(Object o) { return Objects.equals(namespaceUrl, that.namespaceUrl) && Objects.equals(reviewsUrl, that.reviewsUrl) && Objects.equals(files, that.files) + && Objects.equals(downloadSize, that.downloadSize) && Objects.equals(name, that.name) && Objects.equals(namespace, that.namespace) && Objects.equals(targetPlatform, that.targetPlatform) @@ -698,6 +716,7 @@ public int hashCode() { namespaceUrl, reviewsUrl, files, + downloadSize, name, namespace, targetPlatform, diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/FileResourceJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/FileResourceJooqRepository.java index f9c47173a..7dac5723e 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/FileResourceJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/FileResourceJooqRepository.java @@ -44,7 +44,13 @@ public List findByType(Collection extVersions, C } var extVersionsById = extVersions.stream().collect(Collectors.toMap(ExtensionVersion::getId, ev -> ev)); - return dsl.select(FILE_RESOURCE.ID, FILE_RESOURCE.EXTENSION_ID, FILE_RESOURCE.NAME, FILE_RESOURCE.TYPE) + return dsl + .select( + FILE_RESOURCE.ID, + FILE_RESOURCE.EXTENSION_ID, + FILE_RESOURCE.NAME, + FILE_RESOURCE.TYPE, + FILE_RESOURCE.SIZE) .from(FILE_RESOURCE) .where(FILE_RESOURCE.EXTENSION_ID.in(extVersionsById.keySet())).and(FILE_RESOURCE.TYPE.in(types)) .fetch() @@ -52,7 +58,13 @@ public List findByType(Collection extVersions, C } public List findAll(Collection extensionIds, Collection types) { - return dsl.select(FILE_RESOURCE.ID, FILE_RESOURCE.EXTENSION_ID, FILE_RESOURCE.NAME, FILE_RESOURCE.TYPE) + return dsl + .select( + FILE_RESOURCE.ID, + FILE_RESOURCE.EXTENSION_ID, + FILE_RESOURCE.NAME, + FILE_RESOURCE.TYPE, + FILE_RESOURCE.SIZE) .from(FILE_RESOURCE) .where(FILE_RESOURCE.EXTENSION_ID.in(extensionIds).and(FILE_RESOURCE.TYPE.in(types))) .fetch() @@ -76,6 +88,9 @@ private FileResource toFileResource(Record row, ExtensionVersion extVersion) { fileResource.setId(row.get(FILE_RESOURCE.ID)); fileResource.setName(row.get(FILE_RESOURCE.NAME)); fileResource.setType(row.get(FILE_RESOURCE.TYPE)); + // Null for a resource stored before V1_73 added the column that FileResourceSizeJobRequestHandler + // has not backfilled yet, so every reader has to treat the size as unknown rather than as zero. + fileResource.setSize(row.get(FILE_RESOURCE.SIZE)); fileResource.setExtension(extVersion); return fileResource; diff --git a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java index 6629ddc51..8dcaa739f 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java @@ -297,6 +297,28 @@ public long getFileSize(FileResource resource) throws IOException { return storageService.getFileSize(resource); } + /** + * The stored files of these versions, by version id, in one query. + *

+ * {@link #getFileUrls} is the URL-only shorthand over this; a caller that needs more than the URL of a + * file - its size, say - takes the resources themselves rather than paying for a second query to read + * the other column. + */ + public Map> getFiles(Collection extVersions, String... types) { + var byVersion = extVersions.stream() + .map(ev -> Map.>entry(ev.getId(), new ArrayList<>(types.length))) + .collect( + Collectors.>, Long, List>toMap( + Map.Entry::getKey, + Map.Entry::getValue)); + + for (var resource : repositories.findFilesByType(extVersions, Arrays.asList(types))) { + byVersion.get(resource.getExtension().getId()).add(resource); + } + + return byVersion; + } + /** * Returns URLs for the given file types as a map of ExtensionVersion.id by a map of type by file URL, to be used in JSON response data. */ @@ -305,19 +327,17 @@ public Map> getFileUrls( String serverUrl, String... types ) { - var type2Url = extVersions.stream() - .map(ev -> Map.>entry(ev.getId(), new LinkedHashMap<>(types.length))) - .collect( - Collectors.>, Long, Map>toMap( - Map.Entry::getKey, - Map.Entry::getValue)); + var type2Url = HashMap.>newHashMap(extVersions.size()); + getFiles(extVersions, types).forEach((extVersionId, resources) -> { + var urls = new LinkedHashMap(types.length); + for (var resource : resources) { + urls.put( + resource.getType(), + createApiFileUrl(serverUrl, resource.getExtension(), resource.getName())); + } - var resources = repositories.findFilesByType(extVersions, Arrays.asList(types)); - for (var resource : resources) { - var extVersion = resource.getExtension(); - type2Url.get(extVersion.getId()) - .put(resource.getType(), createApiFileUrl(serverUrl, extVersion, resource.getName())); - } + type2Url.put(extVersionId, urls); + }); return type2Url; } diff --git a/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java b/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java index 6044c5835..760ad347e 100644 --- a/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java @@ -169,7 +169,7 @@ void shouldNotDeleteTempFileOnceOwnershipIsHandedToAsyncPublish() throws IOExcep when(extensions.createExtensionFile(any())).thenReturn(tempFile); when(tokens.useAccessToken(eq("tok"), any())).thenReturn(tau); when(extensions.publishVersion(any(ExtensionProcessor.class), eq(tau))).thenReturn(extVersion); - when(storageUtilService.getFileUrls(any(), any(), any(String[].class))).thenReturn(Map.of(42L, Map.of())); + when(storageUtilService.getFiles(any(), any(String[].class))).thenReturn(Map.of(42L, List.of())); registryService.publish(new ByteArrayInputStream(new byte[0]), "tok"); diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index e4f68bea6..a333675d2 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -140,6 +140,9 @@ class RegistryAPITest { */ private static final Duration CHANGES_FEED_LAG = Duration.ofSeconds(30); + /** The size the mocked extension's .vsix reports, in bytes. */ + private static final long DOWNLOAD_SIZE = 1_234_567L; + @MockitoSpyBean UserService users; @@ -220,6 +223,31 @@ void testExtension() throws Exception { }))); } + // The size travels from file_resource.size to the JSON untouched, so that a client - the web UI's + // More Info box among them - can show how big the download is without fetching it. + @Test + void testExtensionDownloadSize() throws Exception { + var extVersion = mockExtension(); + Mockito.when(repositories.findExtensionVersion("foo", "bar", null, VersionAlias.LATEST)).thenReturn(extVersion); + + mockMvc.perform(get("/api/{namespace}/{extension}", "foo", "bar")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.downloadSize").value(DOWNLOAD_SIZE)); + } + + // Null for anything published before the column existed that the backfill has not reached yet. + // Omitted rather than sent as 0, which a client would have no way to tell from a genuinely empty file. + @Test + void testExtensionDownloadSizeUnknown() throws Exception { + var extVersion = mockExtension(); + Mockito.when(repositories.findExtensionVersion("foo", "bar", null, VersionAlias.LATEST)).thenReturn(extVersion); + repositories.findFilesByType(List.of(extVersion), List.of(DOWNLOAD)).forEach(file -> file.setSize(null)); + + mockMvc.perform(get("/api/{namespace}/{extension}", "foo", "bar")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.downloadSize").doesNotExist()); + } + @Test void testExtensionWithPublicKey() throws Exception { Mockito.when(integrityService.isEnabled()).thenReturn(true); @@ -3022,6 +3050,7 @@ private ExtensionVersion mockExtension(String targetPlatform, boolean withSignat download.setType(DOWNLOAD); download.setStorageType(STORAGE_LOCAL); download.setName("extension-1.0.0.vsix"); + download.setSize(DOWNLOAD_SIZE); var signature = new FileResource(); if (withSignature) { signature.setExtension(extVersion); diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 6bfb9bf5b..4f37a4c08 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -416,12 +416,8 @@ void testGetOwnExtensionAsNamespaceOwner() throws Exception { TargetPlatform.NAME_UNIVERSAL, true, false))))); - Mockito.when( - storageUtil.getFileUrls( - Mockito.anyCollection(), - Mockito.anyString(), - Mockito.any(String[].class))) - .thenReturn(java.util.Map.of(42L, new java.util.HashMap<>())); + Mockito.when(storageUtil.getFiles(Mockito.anyCollection(), Mockito.any(String[].class))) + .thenReturn(java.util.Map.of(42L, java.util.List.of())); mockMvc.perform( get("/user/extension/{namespace}/{extension}", "foobar", "baz") .with(user("test_user"))) @@ -472,12 +468,8 @@ void testGetOwnExtensionAsNamespaceMember() throws Exception { TargetPlatform.NAME_UNIVERSAL, true, false))))); - Mockito.when( - storageUtil.getFileUrls( - Mockito.anyCollection(), - Mockito.anyString(), - Mockito.any(String[].class))) - .thenReturn(java.util.Map.of(42L, new java.util.HashMap<>())); + Mockito.when(storageUtil.getFiles(Mockito.anyCollection(), Mockito.any(String[].class))) + .thenReturn(java.util.Map.of(42L, java.util.List.of())); mockMvc.perform( get("/user/extension/{namespace}/{extension}", "foobar", "baz") .with(user("test_user"))) @@ -500,12 +492,8 @@ void testGetOwnExtensionNamespaceOwnershipConflict() throws Exception { Mockito.when(repositories.findLatestVersion(eq("foobar"), eq("baz"), any(), eq(false), eq(false))) .thenReturn(latest); Mockito.when(repositories.findTargetPlatformsGroupedByVersion(extension)).thenReturn(List.of()); - Mockito.when( - storageUtil.getFileUrls( - Mockito.anyCollection(), - Mockito.anyString(), - Mockito.any(String[].class))) - .thenReturn(java.util.Map.of(42L, new java.util.HashMap<>())); + Mockito.when(storageUtil.getFiles(Mockito.anyCollection(), Mockito.any(String[].class))) + .thenReturn(java.util.Map.of(42L, java.util.List.of())); var scan = new ExtensionScan(); scan.setStatus(ScanStatus.QUARANTINED); diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 5b4edfe2a..d5b99ae00 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -70,6 +70,12 @@ export interface Extension { reviewsUrl: UrlString; // key: file type, value: url files: { [id: string]: UrlString }; + /** + * Size in bytes of the .vsix behind `files.download`. Absent where the registry does not know it yet, + * which is the case for extensions published before it started recording sizes. This is the download + * size, not the size the extension takes up once installed. + */ + downloadSize?: number; name: string; namespace: string; diff --git a/webui/src/pages/extension-detail/extension-detail-overview.tsx b/webui/src/pages/extension-detail/extension-detail-overview.tsx index adf8f6538..9fce55ccd 100644 --- a/webui/src/pages/extension-detail/extension-detail-overview.tsx +++ b/webui/src/pages/extension-detail/extension-detail-overview.tsx @@ -16,7 +16,7 @@ import GitHubIcon from '@mui/icons-material/GitHub'; import BugReportIcon from '@mui/icons-material/BugReport'; import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; import { MainContext } from '../../context'; -import { addQuery, createRoute, getTargetPlatformDisplayName, getEngineDisplayName } from '../../utils'; +import { addQuery, createRoute, formatFileSize, getTargetPlatformDisplayName, getEngineDisplayName } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { SanitizedMarkdown } from '../../components/sanitized-markdown'; import { Timestamp } from '../../components/timestamp'; @@ -163,6 +163,19 @@ export const ExtensionDetailOverview: FunctionComponent { + return ( + <> + Size + {formatFileSize(downloadSize)} + + ); + }; + const renderAliasesSection = (otherAliases: string[], sx: SxProps): ReactNode => { const { extension } = props; const aliasButtons = otherAliases.length @@ -361,6 +374,9 @@ export const ExtensionDetailOverview: FunctionComponent {renderIdentifierSection()} + {extension.downloadSize !== undefined ? ( + {renderSizeSection(extension.downloadSize)} + ) : null} diff --git a/webui/test/unit/pages/extension-detail/extension-detail-overview.spec.tsx b/webui/test/unit/pages/extension-detail/extension-detail-overview.spec.tsx new file mode 100644 index 000000000..19310be75 --- /dev/null +++ b/webui/test/unit/pages/extension-detail/extension-detail-overview.spec.tsx @@ -0,0 +1,60 @@ +/******************************************************************************** + * 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 { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { ExtensionDetailOverview } from '../../../../src/pages/extension-detail/extension-detail-overview'; +import { Extension } from '../../../../src/extension-registry-types'; +import { renderWithProviders } from '../../support/test-providers'; + +const extension = (overrides: Partial = {}): Extension => + ({ + name: 'bar', + namespace: 'foo', + namespaceDisplayName: 'Foo', + version: '1.0.0', + displayName: 'Bar Tools', + // Empty, so the component takes its "no README available" path and needs no service. + files: {}, + allVersions: { '1.0.0': 'https://example.com/api/foo/bar/1.0.0' }, + versionAlias: [], + // The detail endpoint always sets this, and the "Works With" section reads it unguarded. + downloads: {}, + downloadCount: 0, + reviewCount: 0, + deprecated: false, + verified: true, + publishedBy: { loginName: 'test_user', homepage: 'https://example.com/test_user' }, + ...overrides + }) as unknown as Extension; + +const renderOverview = (overrides: Partial = {}) => + renderWithProviders( {}} />); + +describe('ExtensionDetailOverview', () => { + it('shows the download size of the extension package', async () => { + renderOverview({ downloadSize: 5 * 1024 * 1024 }); + + expect(await screen.findByText('Size')).toBeInTheDocument(); + expect(screen.getByText('5.00 MB')).toBeInTheDocument(); + }); + + // An extension published before the registry recorded sizes has none until the backfill reaches it. + // The section is left out entirely rather than shown empty or as a misleading zero. + it('omits the size when the registry does not know it', async () => { + renderOverview(); + + expect(await screen.findByText('Unique Identifier')).toBeInTheDocument(); + expect(screen.queryByText('Size')).not.toBeInTheDocument(); + }); +});