feat: show only the latest plugin release notes and cap long ones

This commit is contained in:
LukeGus committed 2026-10-06 19:25:36 -05:00
1 parent 1d3e77227c
commit 7804d4cc8d
3 files changed
+99 -22

No files matched your search

+3 -2
View File
@@ -2108,8 +2108,9 @@
"releaseNotes": "Release notes", "releaseNotes": "Release notes",
"noReleaseNotes": "No notes for this version.", "noReleaseNotes": "No notes for this version.",
"viewRelease": "View on GitHub", "viewRelease": "View on GitHub",
"showAllReleases": "Show all {{count}}", "showOlderReleases": "Older releases ({{count}})",
"showFewerReleases": "Show fewer", "hideOlderReleases": "Hide older releases",
"showMoreChanges": "Show {{count}} more",
"videoTitle": "{{name}} video", "videoTitle": "{{name}} video",
"changeTypes": { "changeTypes": {
"added": "Added", "added": "Added",
+49 -19
View File
@@ -12,7 +12,8 @@ import {
type ReleaseRow, type ReleaseRow,
} from "./plugin-model"; } from "./plugin-model";
const SHOWN = 3; /** A long release shows this many changes until it is opened up. */
const MAX_CHANGES = 8;
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) return ""; if (!value) return "";
@@ -48,7 +49,17 @@ function Release({
installedVersion: string | null; installedVersion: string | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const groups = groupChanges(row.changes); const [full, setFull] = useState(false);
const all = groupChanges(row.changes);
const hidden = full ? 0 : Math.max(0, row.changes.length - MAX_CHANGES);
let budget = full ? Infinity : MAX_CHANGES;
const groups = all
.map((group) => {
const items = group.items.slice(0, Math.max(0, budget));
budget -= items.length;
return { ...group, items };
})
.filter((group) => group.items.length > 0);
return ( return (
<div className="flex flex-col gap-2 px-4 py-3"> <div className="flex flex-col gap-2 px-4 py-3">
@@ -69,7 +80,9 @@ function Release({
</span> </span>
</div> </div>
{row.summary && ( {row.summary && (
<p className="whitespace-pre-line text-[11px] leading-relaxed text-muted-foreground"> <p
className={`whitespace-pre-line text-[11px] leading-relaxed text-muted-foreground ${full ? "" : "line-clamp-4"}`}
>
<InlineText text={row.summary} /> <InlineText text={row.summary} />
</p> </p>
)} )}
@@ -87,6 +100,15 @@ function Release({
</ul> </ul>
</div> </div>
))} ))}
{hidden > 0 && (
<button
type="button"
onClick={() => setFull(true)}
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
>
{t("plugins.manager.showMoreChanges", { count: hidden })}
</button>
)}
{groups.length === 0 && !row.summary && ( {groups.length === 0 && !row.summary && (
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{t("plugins.manager.noReleaseNotes")} {t("plugins.manager.noReleaseNotes")}
@@ -107,13 +129,14 @@ function Release({
} }
/** /**
* Every version's notes, from the registry and the installed CHANGELOG.md. * The newest version's notes, from the registry and the installed
* CHANGELOG.md, with older versions behind a toggle in a capped scroll area.
* Renders nothing when there are no versions to list. * Renders nothing when there are no versions to list.
*/ */
export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) { export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [local, setLocal] = useState<ChangelogRelease[]>([]); const [local, setLocal] = useState<ChangelogRelease[]>([]);
const [expanded, setExpanded] = useState(false); const [showOlder, setShowOlder] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -134,35 +157,42 @@ export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
[plugin.versions, local], [plugin.versions, local],
); );
if (rows.length === 0) return null; if (rows.length === 0) return null;
const shown = expanded ? rows : rows.slice(0, SHOWN); const [latest, ...older] = rows;
return ( return (
<SectionCard <SectionCard
title={t("plugins.manager.releaseNotes")} title={t("plugins.manager.releaseNotes")}
icon={null} icon={null}
action={ action={
rows.length > SHOWN ? ( older.length > 0 ? (
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
className="text-muted-foreground" className="text-muted-foreground"
onClick={() => setExpanded((v) => !v)} onClick={() => setShowOlder((v) => !v)}
> >
{expanded {showOlder
? t("plugins.manager.showFewerReleases") ? t("plugins.manager.hideOlderReleases")
: t("plugins.manager.showAllReleases", { count: rows.length })} : t("plugins.manager.showOlderReleases", {
count: older.length,
})}
</Button> </Button>
) : undefined ) : undefined
} }
> >
<div className="-mx-3 -my-1 divide-y divide-border md:-mx-4"> <div className="-mx-3 -my-1 md:-mx-4">
{shown.map((row) => ( <Release row={latest} installedVersion={plugin.version} />
<Release {showOlder && older.length > 0 && (
key={row.version} <div className="max-h-96 divide-y divide-border overflow-y-auto border-t border-border">
row={row} {older.map((row) => (
installedVersion={plugin.version} <Release
/> key={row.version}
))} row={row}
installedVersion={plugin.version}
/>
))}
</div>
)}
</div> </div>
</SectionCard> </SectionCard>
); );
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import i18next from "i18next"; import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next"; import { I18nextProvider, initReactI18next } from "react-i18next";
import type { PluginEntry } from "@/plugins/plugin-model"; import type { PluginEntry } from "@/plugins/plugin-model";
@@ -27,6 +33,9 @@ async function i18n() {
releaseNotes: "Release notes", releaseNotes: "Release notes",
installedBadge: "Installed", installedBadge: "Installed",
changeTypes: { added: "Added", fixed: "Fixed" }, changeTypes: { added: "Added", fixed: "Fixed" },
showOlderReleases: "Older releases ({{count}})",
hideOlderReleases: "Hide older releases",
showMoreChanges: "Show {{count}} more",
videoTitle: "{{name}} video", videoTitle: "{{name}} video",
}, },
}, },
@@ -71,6 +80,43 @@ describe("PluginReleaseNotes", () => {
expect(screen.getByText("Installed")).toBeTruthy(); expect(screen.getByText("Installed")).toBeTruthy();
}); });
it("shows only the newest release until older ones are opened", async () => {
getPluginChangelog.mockResolvedValue([
{ version: "1.1.0", changes: [{ type: "fixed", text: "Newest" }] },
{ version: "1.0.0", changes: [{ type: "added", text: "Oldest" }] },
]);
render(
<I18nextProvider i18n={await i18n()}>
<PluginReleaseNotes plugin={plugin({ version: "1.1.0" })} />
</I18nextProvider>,
);
await waitFor(() => expect(screen.getByText("Newest")).toBeTruthy());
expect(screen.queryByText("Oldest")).toBeNull();
fireEvent.click(screen.getByText("Older releases (1)"));
expect(screen.getByText("Oldest")).toBeTruthy();
});
it("cuts a long release down until it is opened up", async () => {
getPluginChangelog.mockResolvedValue([
{
version: "1.0.0",
changes: Array.from({ length: 12 }, (_, i) => ({
type: "added",
text: `Change ${i}`,
})),
},
]);
render(
<I18nextProvider i18n={await i18n()}>
<PluginReleaseNotes plugin={plugin()} />
</I18nextProvider>,
);
await waitFor(() => expect(screen.getByText("Change 7")).toBeTruthy());
expect(screen.queryByText("Change 8")).toBeNull();
fireEvent.click(screen.getByText("Show 4 more"));
expect(screen.getByText("Change 11")).toBeTruthy();
});
it("renders nothing for a plugin with no versions", async () => { it("renders nothing for a plugin with no versions", async () => {
const { container } = render( const { container } = render(
<I18nextProvider i18n={await i18n()}> <I18nextProvider i18n={await i18n()}>