From 135119b6c4f8676dab6387ad8cc9ae92ea54bec6 Mon Sep 17 00:00:00 2001 From: Brandon Geraci Date: Tue, 2 Jun 2026 11:28:34 -0500 Subject: [PATCH] fix(repositories): pagination, file click-through, and full file listing in Maven grouped view Grouped (Maven/Gradle) mode in the repository browser had three defects: - #443: the grouped view rendered no pagination, so only the first 20 GAV components were ever shown. Extracted the DataTable pagination markup into a shared DataTablePagination component and wired it into MavenComponentList, driven by the same page/pageSize state the flat list uses. - #444: file rows inside an expanded GAV group were plain text and did nothing on click. They are now buttons that reconstruct the Maven path (groupId/artifactId/version/filename) and open the artifact detail dialog, matching flat-mode behaviour. - #445: non-jar files such as .zip and checksum sidecars were not openable in grouped mode. The grouped response already carries every filename per GAV; surfacing each as a clickable row now exposes them with full metadata via the detail dialog. Also fixed artifactsApi.get to encode path segments individually so the backend wildcard route (/:key/artifacts/*path) matches reconstructed paths instead of 404ing on percent-encoded slashes. Closes #443 Closes #444 Closes #445 --- .../maven-grouped-browser.spec.ts | 185 ++++++++++++++++++ .../__tests__/maven-component-list.test.tsx | 83 +++++++- .../__tests__/maven-component-path.test.ts | 38 ++++ .../_components/maven-component-list.tsx | 88 ++++++--- .../_components/maven-component-path.ts | 26 +++ .../_components/repo-detail-content.tsx | 24 +++ .../common/data-table-pagination.tsx | 100 ++++++++++ src/components/common/data-table.tsx | 70 +------ src/lib/api/__tests__/artifacts.test.ts | 25 +++ src/lib/api/artifacts.ts | 19 +- 10 files changed, 562 insertions(+), 96 deletions(-) create mode 100644 e2e/suites/interactions/repositories/maven-grouped-browser.spec.ts create mode 100644 src/app/(app)/repositories/_components/__tests__/maven-component-path.test.ts create mode 100644 src/app/(app)/repositories/_components/maven-component-path.ts create mode 100644 src/components/common/data-table-pagination.tsx diff --git a/e2e/suites/interactions/repositories/maven-grouped-browser.spec.ts b/e2e/suites/interactions/repositories/maven-grouped-browser.spec.ts new file mode 100644 index 00000000..02240191 --- /dev/null +++ b/e2e/suites/interactions/repositories/maven-grouped-browser.spec.ts @@ -0,0 +1,185 @@ +import { test, expect, type APIRequestContext } from '@playwright/test'; + +/** + * Regression tests for the Maven repository browser in GROUPED mode + * (issues #443, #444, #445). + * + * #443 — pagination must render in grouped mode so users can reach page 2+ + * instead of being stuck on the first 20 components. + * #444 — clicking a file inside a GAV group must open the artifact detail + * dialog (this already worked in flat mode). + * #445 — every file deployed for a GAV must be listed, including non-jar + * files such as .zip and the checksum sidecars. + * + * Strategy mirrors artifact-download.spec.ts: seed artifacts through the API, + * then drive the real UI. The repository `e2e-maven-local` is created by the + * E2E seed step. + */ +test.describe('Maven Grouped Browser (#443, #444, #445)', () => { + const REPO_KEY = 'e2e-maven-local'; + // Unique groupId per run so parallel/retried runs don't collide and so the + // assertions are deterministic regardless of what else lives in the repo. + const RUN = `g${Date.now()}`; + const GROUP_PATH = `com/example/${RUN}`; + const GROUP_ID = `com.example.${RUN}`; + + /** PUT one file at a full Maven coordinate path. */ + async function put( + request: APIRequestContext, + relPath: string, + body: string, + ): Promise { + const resp = await request.put( + `/api/v1/repositories/${REPO_KEY}/artifacts/${relPath}`, + { data: body, headers: { 'Content-Type': 'application/octet-stream' } }, + ); + expect(resp.ok(), `PUT ${relPath} failed: ${resp.status()}`).toBeTruthy(); + } + + /** + * Deploy `count` distinct GAV components (one artifact + pom each) so the + * grouped listing has more than one page at the default page size of 20. + * Returns the list of artifactIds created. + */ + async function seedManyComponents( + request: APIRequestContext, + count: number, + ): Promise { + const ids: string[] = []; + for (let i = 0; i < count; i++) { + const artifactId = `lib-${i}`; + const version = '1.0.0'; + const base = `${GROUP_PATH}/${artifactId}/${version}/${artifactId}-${version}`; + await put(request, `${base}.jar`, `jar-${i}`); + await put(request, `${base}.pom`, `${i}`); + ids.push(artifactId); + } + return ids; + } + + /** + * Deploy a single GAV with a POM, a JAR, and a ZIP plus a checksum sidecar + * so #445 can assert that non-jar files are listed. + */ + async function seedGavWithZip(request: APIRequestContext): Promise<{ + artifactId: string; + files: string[]; + }> { + const artifactId = 'with-zip'; + const version = '2.0.0'; + const base = `${GROUP_PATH}/${artifactId}/${version}/${artifactId}-${version}`; + const files = [ + `${artifactId}-${version}.pom`, + `${artifactId}-${version}.jar`, + `${artifactId}-${version}.zip`, + `${artifactId}-${version}.jar.sha1`, + ]; + await put(request, `${base}.pom`, 'zip'); + await put(request, `${base}.jar`, 'jar-bytes'); + await put(request, `${base}.zip`, 'zip-bytes'); + await put(request, `${base}.jar.sha1`, 'abc123'); + return { artifactId, files }; + } + + async function gotoGrouped( + page: import('@playwright/test').Page, + ): Promise { + // `?view=grouped` forces grouped mode regardless of stored preference. + await page.goto(`/repositories/${REPO_KEY}?view=grouped`); + await page.waitForLoadState('domcontentloaded'); + const groupedBtn = page.getByTestId('toggle-grouped'); + await expect(groupedBtn).toBeVisible({ timeout: 10000 }); + if ((await groupedBtn.getAttribute('aria-pressed')) !== 'true') { + await groupedBtn.click(); + } + } + + test('#443: grouped mode renders pagination when there are many components', async ({ + page, + request, + }) => { + // 25 components > default page size of 20, so a second page must exist. + await seedManyComponents(request, 25); + + await gotoGrouped(page); + + const list = page.getByTestId('maven-component-list'); + await expect(list).toBeVisible({ timeout: 15000 }); + + // The pagination control must be present (the bug: it was never rendered). + const pagination = page.getByTestId('data-table-pagination'); + await expect(pagination).toBeVisible(); + + // "Page 1 of N" with N >= 2 proves more than one page exists. + await expect(pagination).toContainText(/page 1 of [2-9]\d*/i); + + // Navigating to the next page must update the indicator. + const nextBtn = pagination.getByRole('button', { name: /next page/i }); + await expect(nextBtn).toBeEnabled(); + await nextBtn.click(); + await expect(pagination).toContainText(/page 2 of/i); + }); + + test('#444: clicking a file inside a grouped GAV opens artifact details', async ({ + page, + request, + }) => { + const { artifactId } = await seedGavWithZip(request); + + await gotoGrouped(page); + + // The data-gav attribute lives on the
  • row itself; locate it directly. + const gavRow = page.locator( + `[data-testid="maven-component-row"][data-gav="${GROUP_ID}:${artifactId}:2.0.0"]`, + ); + await expect(gavRow).toBeVisible({ timeout: 15000 }); + + const trigger = gavRow.locator('button[aria-expanded]').first(); + await trigger.click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + const files = gavRow.getByTestId('maven-component-files'); + await expect(files).toBeVisible(); + + // Click the .jar file row — this must open the detail dialog. + const jarRow = files.getByText(`${artifactId}-2.0.0.jar`, { exact: true }); + await jarRow.click(); + + // The detail dialog shows a "Download URL" field (see flat-mode detail). + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await expect(dialog.getByText(/download url/i)).toBeVisible(); + }); + + test('#445: grouped mode lists all GAV files including the .zip and checksums', async ({ + page, + request, + }) => { + const { artifactId, files } = await seedGavWithZip(request); + + await gotoGrouped(page); + + const gavRow = page.locator( + `[data-testid="maven-component-row"][data-gav="${GROUP_ID}:${artifactId}:2.0.0"]`, + ); + await expect(gavRow).toBeVisible({ timeout: 15000 }); + + const trigger = gavRow.locator('button[aria-expanded]').first(); + await trigger.click(); + + const fileList = gavRow.getByTestId('maven-component-files'); + await expect(fileList).toBeVisible(); + + // Every deployed file must appear, crucially the non-jar .zip and the + // checksum sidecar that the bug report said were missing. + for (const f of files) { + await expect( + fileList.getByText(f, { exact: true }), + `file ${f} should be listed in the GAV group`, + ).toBeVisible(); + } + + // The file-count badge on the collapsed summary must reflect all 4 files. + await expect(gavRow).toContainText('4 files'); + }); +}); diff --git a/src/app/(app)/repositories/_components/__tests__/maven-component-list.test.tsx b/src/app/(app)/repositories/_components/__tests__/maven-component-list.test.tsx index 27b39f47..0f0bfeeb 100644 --- a/src/app/(app)/repositories/_components/__tests__/maven-component-list.test.tsx +++ b/src/app/(app)/repositories/_components/__tests__/maven-component-list.test.tsx @@ -180,21 +180,84 @@ describe("MavenComponentList", () => { }); // --------------------------------------------------------------------- - // "Showing N of M" footer + // Pagination (issue #443) // --------------------------------------------------------------------- - it("shows the 'showing N of M' helper when total exceeds rendered count", () => { - render(); - expect(screen.getByText(/showing 1 of 42 components/i)).toBeInTheDocument(); + it("renders pagination controls when total is provided", () => { + render( + {}} + />, + ); + expect(screen.getByTestId("data-table-pagination")).toBeInTheDocument(); + // 42 components / 20 per page => 3 pages + expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument(); + expect(screen.getByText(/1-20 of 42/i)).toBeInTheDocument(); }); - it("hides the helper footer when total equals rendered count", () => { - render(); - expect(screen.queryByText(/showing/i)).not.toBeInTheDocument(); + it("does not render pagination when total is undefined", () => { + render(); + expect(screen.queryByTestId("data-table-pagination")).not.toBeInTheDocument(); }); - it("hides the helper footer when total is undefined", () => { - render(); - expect(screen.queryByText(/showing/i)).not.toBeInTheDocument(); + it("invokes onPageChange when the next-page button is clicked", async () => { + const onPageChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /next page/i })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + // --------------------------------------------------------------------- + // Clickable file rows (issues #444, #445) + // --------------------------------------------------------------------- + + it("invokes onFileSelect with the reconstructed Maven path when a file is clicked", async () => { + const onFileSelect = vi.fn(); + render( + , + ); + const trigger = screen.getByRole("button", { name: /org\.junit\.jupiter/ }); + await userEvent.click(trigger); + + const fileList = await screen.findByTestId("maven-component-files"); + await userEvent.click( + within(fileList).getByText("junit-jupiter-api-5.11.0.jar"), + ); + + expect(onFileSelect).toHaveBeenCalledWith( + "org/junit/jupiter/junit-jupiter-api/5.11.0/junit-jupiter-api-5.11.0.jar", + "junit-jupiter-api-5.11.0.jar", + ); + }); + + it("lists every file in a component, including non-jar files like .zip", async () => { + const withZip: MavenComponent = { + ...COMP_B, + artifact_files: [ + "lib-1.0.0.pom", + "lib-1.0.0.zip", + "lib-1.0.0.jar.sha1", + ], + }; + render(); + const trigger = screen.getByRole("button", { name: /com\.example/ }); + await userEvent.click(trigger); + + const fileList = await screen.findByTestId("maven-component-files"); + expect(within(fileList).getByText("lib-1.0.0.pom")).toBeInTheDocument(); + expect(within(fileList).getByText("lib-1.0.0.zip")).toBeInTheDocument(); + expect(within(fileList).getByText("lib-1.0.0.jar.sha1")).toBeInTheDocument(); }); }); diff --git a/src/app/(app)/repositories/_components/__tests__/maven-component-path.test.ts b/src/app/(app)/repositories/_components/__tests__/maven-component-path.test.ts new file mode 100644 index 00000000..d318ba85 --- /dev/null +++ b/src/app/(app)/repositories/_components/__tests__/maven-component-path.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; + +import { mavenFilePath } from "../maven-component-path"; + +describe("mavenFilePath", () => { + it("maps dotted groupId to a slash path and appends artifact/version/filename", () => { + expect( + mavenFilePath( + { group_id: "org.junit.jupiter", artifact_id: "junit-jupiter-api", version: "5.11.0" }, + "junit-jupiter-api-5.11.0.jar", + ), + ).toBe("org/junit/jupiter/junit-jupiter-api/5.11.0/junit-jupiter-api-5.11.0.jar"); + }); + + it("handles a single-segment groupId", () => { + expect( + mavenFilePath( + { group_id: "example", artifact_id: "demo", version: "1.0.0" }, + "demo-1.0.0.zip", + ), + ).toBe("example/demo/1.0.0/demo-1.0.0.zip"); + }); + + it("preserves non-jar file extensions such as .zip and checksum files", () => { + expect( + mavenFilePath( + { group_id: "com.example", artifact_id: "lib", version: "2.0.0" }, + "lib-2.0.0.zip", + ), + ).toBe("com/example/lib/2.0.0/lib-2.0.0.zip"); + expect( + mavenFilePath( + { group_id: "com.example", artifact_id: "lib", version: "2.0.0" }, + "lib-2.0.0.pom.sha512", + ), + ).toBe("com/example/lib/2.0.0/lib-2.0.0.pom.sha512"); + }); +}); diff --git a/src/app/(app)/repositories/_components/maven-component-list.tsx b/src/app/(app)/repositories/_components/maven-component-list.tsx index cca89812..9b4c970d 100644 --- a/src/app/(app)/repositories/_components/maven-component-list.tsx +++ b/src/app/(app)/repositories/_components/maven-component-list.tsx @@ -11,8 +11,10 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { DataTablePagination } from "@/components/common/data-table-pagination"; import { cn, formatBytes } from "@/lib/utils"; import type { MavenComponent } from "@/types"; +import { mavenFilePath } from "./maven-component-path"; interface MavenComponentListProps { components: MavenComponent[]; @@ -20,16 +22,30 @@ interface MavenComponentListProps { emptyMessage?: string; /** * Total component count from the server. Used for the "showing N of M" - * helper text when paginated. Optional. + * helper text and to drive pagination when paginated. Optional. */ total?: number; + /** Current 1-based page (server-side pagination, issue #443). */ + page?: number; + /** Components per page. */ + pageSize?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (size: number) => void; + /** + * Called when an individual file within a component group is clicked. + * Receives the repository-relative artifact path so the caller can open + * the artifact detail dialog (issue #444). Without this, clicking a file + * row does nothing. + */ + onFileSelect?: (filePath: string, filename: string) => void; } /** * Renders Maven/Gradle artifacts grouped by GAV (groupId, artifactId, * version). Each component row is a collapsible disclosure: collapsed it * shows summary stats; expanded it reveals the individual filenames (jar, - * pom, checksums, …) that share the same coordinates. + * pom, zip, checksums, …) that share the same coordinates. Each file row is + * clickable and opens the artifact detail dialog (issues #444, #445). * * Source: backend ak#701 — `?group_by=maven_component`. */ @@ -39,6 +55,11 @@ export function MavenComponentList({ // M7: actionable default — tells the user what to do, not just that it's empty. emptyMessage = "No Maven components found. Switch to Flat to see raw files, or push an artifact with valid GAV coordinates.", total, + page = 1, + pageSize = 20, + onPageChange, + onPageSizeChange, + onFileSelect, }: MavenComponentListProps) { if (loading) { return ( @@ -71,16 +92,28 @@ export function MavenComponentList({ } return ( -
    -
      - {components.map((c) => ( - - ))} -
    - {typeof total === "number" && total > components.length && ( -
    - Showing {components.length} of {total} components -
    +
    +
    +
      + {components.map((c) => ( + + ))} +
    +
    + {/* Pagination over the GAV components themselves (issue #443). */} + {typeof total === "number" && ( + )}
    ); @@ -88,9 +121,10 @@ export function MavenComponentList({ interface MavenComponentRowProps { component: MavenComponent; + onFileSelect?: (filePath: string, filename: string) => void; } -function MavenComponentRow({ component }: MavenComponentRowProps) { +function MavenComponentRow({ component, onFileSelect }: MavenComponentRowProps) { const [open, setOpen] = useState(false); const fileCount = component.artifact_files.length; @@ -162,15 +196,25 @@ function MavenComponentRow({ component }: MavenComponentRowProps) { data-testid="maven-component-files" role="list" > - {component.artifact_files.map((filename) => ( -
  • -
  • - ))} + {component.artifact_files.map((filename) => { + const filePath = mavenFilePath(component, filename); + return ( +
  • + +
  • + ); + })} diff --git a/src/app/(app)/repositories/_components/maven-component-path.ts b/src/app/(app)/repositories/_components/maven-component-path.ts new file mode 100644 index 00000000..e7fdafc2 --- /dev/null +++ b/src/app/(app)/repositories/_components/maven-component-path.ts @@ -0,0 +1,26 @@ +import type { MavenComponent } from "@/types"; + +/** + * Reconstruct the repository-relative storage path for a single file within a + * grouped Maven component. + * + * Maven layout is `groupId/artifactId/version/filename`, where `groupId`'s + * dots map to path separators. The grouped listing endpoint + * (`?group_by=maven_component`) only returns bare filenames per component + * (see backend `MavenComponentResponse.artifact_files`), so the web UI + * rebuilds the full path here in order to open the artifact detail dialog and + * fetch per-file metadata (issues #444, #445). + * + * @example + * mavenFilePath( + * { group_id: "org.example", artifact_id: "demo", version: "1.0.0", ... }, + * "demo-1.0.0.zip", + * ) // => "org/example/demo/1.0.0/demo-1.0.0.zip" + */ +export function mavenFilePath( + component: Pick, + filename: string, +): string { + const groupPath = component.group_id.split(".").join("/"); + return `${groupPath}/${component.artifact_id}/${component.version}/${filename}`; +} diff --git a/src/app/(app)/repositories/_components/repo-detail-content.tsx b/src/app/(app)/repositories/_components/repo-detail-content.tsx index c5309209..cc51613b 100644 --- a/src/app/(app)/repositories/_components/repo-detail-content.tsx +++ b/src/app/(app)/repositories/_components/repo-detail-content.tsx @@ -274,6 +274,22 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon setDetailOpen(true); }, []); + // Grouped (Maven) view only knows a file's path, not its full Artifact + // record. Fetch the detail on demand so clicking a file row inside a GAV + // group opens the same dialog as the flat list (issues #444, #445). + const showDetailByPath = useCallback( + async (filePath: string, filename: string) => { + try { + const artifact = await artifactsApi.get(repoKey, filePath); + setSelectedArtifact(artifact); + setDetailOpen(true); + } catch { + toast.error(`Could not load details for ${filename}`); + } + }, + [repoKey], + ); + // --- artifact columns --- const artifactColumns: DataTableColumn[] = [ { @@ -643,6 +659,14 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon components={artifactsData?.components ?? []} loading={artifactsLoading} total={artifactsData?.pagination?.total} + page={page} + pageSize={pageSize} + onPageChange={setPage} + onPageSizeChange={(s) => { + setPageSize(s); + setPage(1); + }} + onFileSelect={showDetailByPath} emptyMessage="No Maven components could be grouped — switch to flat view to see raw files." /> ) : isDockerGrouped ? ( diff --git a/src/components/common/data-table-pagination.tsx b/src/components/common/data-table-pagination.tsx new file mode 100644 index 00000000..74ac72e7 --- /dev/null +++ b/src/components/common/data-table-pagination.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; + +export interface DataTablePaginationProps { + /** Total number of items across all pages (server-side total). */ + total: number; + /** Current 1-based page. */ + page: number; + /** Number of items per page. */ + pageSize: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (size: number) => void; + pageSizeOptions?: number[]; + /** Noun used in the "N results" / range label (default: "results"). */ + itemLabel?: string; +} + +/** + * Shared pagination control used by `DataTable` (flat artifact list) and the + * grouped Maven component view. Extracted so both surfaces show identical + * controls and the grouped view gets real pagination (issue #443) without + * duplicating the markup. + * + * Renders nothing when neither `onPageChange` nor `onPageSizeChange` is + * supplied, matching the previous inline behaviour in `DataTable`. + */ +export function DataTablePagination({ + total, + page, + pageSize, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 20, 50, 100], + itemLabel = "results", +}: DataTablePaginationProps) { + if (!onPageChange && !onPageSizeChange) return null; + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const rangeStart = total > 0 ? (page - 1) * pageSize + 1 : 0; + const rangeEnd = Math.min(page * pageSize, total); + + return ( +
    +
    + Rows per page + + + {total > 0 ? `${rangeStart}-${rangeEnd} of ${total}` : `0 ${itemLabel}`} + +
    +
    + + + Page {page} of {totalPages} + + +
    +
    + ); +} diff --git a/src/components/common/data-table.tsx b/src/components/common/data-table.tsx index 982351fb..a4b37f68 100644 --- a/src/components/common/data-table.tsx +++ b/src/components/common/data-table.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useCallback } from "react"; -import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; import { Table, TableHeader, @@ -10,16 +10,9 @@ import { TableRow, TableCell, } from "@/components/ui/table"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { DataTablePagination } from "@/components/common/data-table-pagination"; export interface DataTableColumn { id: string; @@ -102,7 +95,6 @@ export function DataTable({ }, [data, sortColumn, sortDir, columns]); const totalItems = total ?? data.length; - const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); if (loading) { return ( @@ -239,56 +231,14 @@ export function DataTable({ {/* Pagination */} - {(onPageChange || onPageSizeChange) && ( -
    -
    - Rows per page - - - {totalItems > 0 - ? `${(page - 1) * pageSize + 1}-${Math.min(page * pageSize, totalItems)} of ${totalItems}` - : "0 results"} - -
    -
    - - - Page {page} of {totalPages} - - -
    -
    - )} + ); } diff --git a/src/lib/api/__tests__/artifacts.test.ts b/src/lib/api/__tests__/artifacts.test.ts index 1b5a43f3..ee50f142 100644 --- a/src/lib/api/__tests__/artifacts.test.ts +++ b/src/lib/api/__tests__/artifacts.test.ts @@ -224,6 +224,31 @@ describe("artifactsApi", () => { vi.restoreAllMocks(); }); + it("get preserves path separators for the wildcard route (#444/#445)", async () => { + // The backend route is `/:key/artifacts/*path`; encoding the whole path + // with encodeURIComponent would turn `/` into `%2F` and 404. Slashes + // must survive while individual segments are still escaped. + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ id: "a1" }), + }); + global.fetch = fetchMock; + + const { artifactsApi } = await import("../artifacts"); + await artifactsApi.get( + "repo-key", + "com/example/with zip/1.0.0/with-1.0.0.zip" + ); + + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain( + "/api/v1/repositories/repo-key/artifacts/com/example/with%20zip/1.0.0/with-1.0.0.zip" + ); + expect(calledUrl).not.toContain("%2F"); + + vi.restoreAllMocks(); + }); + it("get throws on non-ok response", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 }); diff --git a/src/lib/api/artifacts.ts b/src/lib/api/artifacts.ts index e69d882c..914e895b 100644 --- a/src/lib/api/artifacts.ts +++ b/src/lib/api/artifacts.ts @@ -127,12 +127,23 @@ export const artifactsApi = { get: async (repoKey: string, artifactPath: string): Promise => { // The SDK uses getRepositoryArtifactMetadata for GET /api/v1/repositories/{key}/artifacts/{path} - // but the original code uses a URL-encoded path. Use the SDK's downloadArtifact metadata or - // fall back to a direct fetch since the SDK's getArtifact uses /api/v1/artifacts/{id} which - // is a different endpoint. + // but the SDK's getArtifact uses /api/v1/artifacts/{id} which is a different endpoint, so we + // fetch directly. + // + // The backend route is `/:key/artifacts/*path` (an Axum wildcard), so the + // path segment separators must stay as literal slashes — encoding the + // whole path with encodeURIComponent (turning `/` into `%2F`) breaks the + // wildcard match and 404s. Encode each segment individually instead so + // slashes survive but other reserved characters are still escaped. This + // also matters for Maven grouped-mode detail lookups (#444, #445), where + // the path is reconstructed as groupId/artifactId/version/filename. const baseUrl = getActiveInstanceBaseUrl(); + const encodedPath = artifactPath + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); const response = await fetch( - `${baseUrl}/api/v1/repositories/${repoKey}/artifacts/${encodeURIComponent(artifactPath)}`, + `${baseUrl}/api/v1/repositories/${repoKey}/artifacts/${encodedPath}`, { credentials: 'include' } ); if (!response.ok) {