diff --git a/next-env.d.ts b/next-env.d.ts
deleted file mode 100644
index c4b7818f..00000000
--- a/next-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-import "./.next/dev/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx
index 27a1776b..d8aa27e5 100644
--- a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx
+++ b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx
@@ -35,6 +35,8 @@ const DiffIntro = forwardRef, DiffIntroProps>(
left={
}
@@ -46,6 +48,8 @@ const DiffIntro = forwardRef, DiffIntroProps>(
right={
}
diff --git a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx
index 56aadf74..64db798c 100644
--- a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx
+++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx
@@ -1,5 +1,6 @@
import { forwardRef, type HTMLAttributes } from "react";
import Pkg from "^/components/ui/Pkg";
+import PkgWithVersionSelector from "^/components/ui/PkgWithVersionSelector";
import { cx } from "^/lib/cva";
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
import { simplePackageSpecToString } from "^/lib/SimplePackageSpec";
@@ -9,12 +10,25 @@ import ServiceLinks from "./ServiceLinks";
interface SpecBoxProps extends HTMLAttributes {
pkg: SimplePackageSpec;
pkgClassName?: string;
+ /** The other package spec (for interactive version selection) */
+ otherPkg?: SimplePackageSpec;
+ /** Which side this is ('a' or 'b') - required for version selection */
+ side?: "a" | "b";
}
const SpecBox = forwardRef(
- ({ pkg, pkgClassName, ...props }, ref) => (
+ ({ pkg, pkgClassName, otherPkg, side, ...props }, ref) => (
-
+ {otherPkg && side ? (
+
+ ) : (
+
+ )}
({
+ useRouter: jest.fn(),
+}));
+
+// Mock fetch
+global.fetch = jest.fn();
+
+describe("VersionSelector", () => {
+ const mockPush = jest.fn();
+ const mockFetch = global.fetch as jest.MockedFunction;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useRouter as jest.Mock).mockReturnValue({
+ push: mockPush,
+ });
+ });
+
+ it("renders loading state initially", () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ } as Response);
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("18.0.0")).toBeInTheDocument();
+ });
+
+ it("fetches and displays versions", async () => {
+ const mockVersions = [
+ { version: "18.2.0", time: "2023-01-01" },
+ { version: "18.1.0", time: "2022-12-01" },
+ { version: "18.0.0", time: "2022-11-01" },
+ ];
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockVersions,
+ } as Response);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+ });
+
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
+ expect(select.value).toBe("18.0.0");
+ });
+
+ it("navigates to new diff when version is selected (side a)", async () => {
+ const mockVersions = [
+ { version: "18.2.0", time: "2023-01-01" },
+ { version: "18.0.0", time: "2022-11-01" },
+ ];
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockVersions,
+ } as Response);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+ });
+
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
+ fireEvent.change(select, { target: { value: "18.2.0" } });
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ "/react@18.2.0...react@17.0.0",
+ );
+ });
+ });
+
+ it("navigates to new diff when version is selected (side b)", async () => {
+ const mockVersions = [
+ { version: "18.2.0", time: "2023-01-01" },
+ { version: "17.0.0", time: "2022-11-01" },
+ ];
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockVersions,
+ } as Response);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+ });
+
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
+ fireEvent.change(select, { target: { value: "18.2.0" } });
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ "/react@18.0.0...react@18.2.0",
+ );
+ });
+ });
+
+ it("displays version tags when available", async () => {
+ const mockVersions = [
+ {
+ version: "18.2.0",
+ time: "2023-01-01",
+ tags: ["latest", "next"],
+ },
+ { version: "18.0.0", time: "2022-11-01" },
+ ];
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockVersions,
+ } as Response);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+ });
+
+ // Check if option with tags exists
+ const option = screen.getByText("18.2.0 (latest, next)");
+ expect(option).toBeInTheDocument();
+ });
+});
diff --git a/src/app/[...parts]/_page/DiffIntro/VersionSelector.tsx b/src/app/[...parts]/_page/DiffIntro/VersionSelector.tsx
new file mode 100644
index 00000000..16404734
--- /dev/null
+++ b/src/app/[...parts]/_page/DiffIntro/VersionSelector.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { type Version } from "^/app/api/-/versions/types";
+import type SimplePackageSpec from "^/lib/SimplePackageSpec";
+
+interface VersionSelectorProps {
+ /** The current package spec */
+ currentSpec: SimplePackageSpec;
+ /** The other package spec (to keep in the diff) */
+ otherSpec: SimplePackageSpec;
+ /** Whether this is the 'a' (left/from) or 'b' (right/to) side */
+ side: "a" | "b";
+ /** Class name for the select element */
+ className?: string;
+}
+
+export default function VersionSelector({
+ currentSpec,
+ otherSpec,
+ side,
+ className,
+}: VersionSelectorProps) {
+ const router = useRouter();
+ const [versions, setVersions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [hasError, setHasError] = useState(false);
+
+ useEffect(() => {
+ const fetchVersions = async () => {
+ try {
+ setIsLoading(true);
+ setHasError(false);
+ const response = await fetch(
+ `/api/-/versions?package=${encodeURIComponent(currentSpec.name)}`,
+ );
+ if (response.ok) {
+ const data: Version[] = await response.json();
+ setVersions(data);
+ } else {
+ console.error(
+ `Failed to fetch versions: ${response.status} ${response.statusText}`,
+ );
+ setHasError(true);
+ }
+ } catch (error) {
+ console.error("Failed to fetch versions:", error);
+ setHasError(true);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchVersions();
+ }, [currentSpec.name]);
+
+ const handleVersionChange = (newVersion: string) => {
+ if (newVersion === currentSpec.version) {
+ return; // No change
+ }
+
+ // Build the new URL based on which side we're updating
+ const newA =
+ side === "a"
+ ? `${currentSpec.name}@${newVersion}`
+ : `${otherSpec.name}@${otherSpec.version}`;
+ const newB =
+ side === "b"
+ ? `${currentSpec.name}@${newVersion}`
+ : `${otherSpec.name}@${otherSpec.version}`;
+
+ // Navigate to the new diff URL
+ router.push(`/${newA}...${newB}`);
+ };
+
+ if (isLoading) {
+ return (
+
+ {currentSpec.version}
+
+ );
+ }
+
+ if (hasError || versions.length === 0) {
+ // Fallback to plain text if we can't fetch versions
+ return {currentSpec.version};
+ }
+
+ // Sort versions in reverse chronological order (newest first)
+ const sortedVersions = [...versions].sort((a, b) => {
+ return new Date(b.time).getTime() - new Date(a.time).getTime();
+ });
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/PkgWithVersionSelector.tsx b/src/components/ui/PkgWithVersionSelector.tsx
new file mode 100644
index 00000000..a2a1861e
--- /dev/null
+++ b/src/components/ui/PkgWithVersionSelector.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { type ElementRef, forwardRef } from "react";
+import VersionSelector from "^/app/[...parts]/_page/DiffIntro/VersionSelector";
+import type SimplePackageSpec from "^/lib/SimplePackageSpec";
+import Code, { type CodeProps } from "./Code";
+
+export interface PkgWithVersionSelectorProps extends CodeProps {
+ pkg: SimplePackageSpec;
+ otherPkg: SimplePackageSpec;
+ side: "a" | "b";
+}
+
+const PkgWithVersionSelector = forwardRef<
+ ElementRef,
+ PkgWithVersionSelectorProps
+>(
+ (
+ {
+ pkg,
+ otherPkg,
+ side,
+ className,
+ ...props
+ }: PkgWithVersionSelectorProps,
+ ref,
+ ) => {
+ return (
+
+ {pkg.name}@
+
+
+ );
+ },
+);
+PkgWithVersionSelector.displayName = "PkgWithVersionSelector";
+
+export default PkgWithVersionSelector;