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(); + }); +});