feat: Journal before the version history in Settings → System, history folded by version (#372) #470

Open
maximus wants to merge 3 commits from issue-372-systems-journal-first into issue-449-cargo-locked
8 changed files with 940 additions and 79 deletions
Showing only changes of commit 92b3e54d54 - Show all commits

View file

@ -0,0 +1,338 @@
// ChangelogContent — the version history folds by version (#372).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// component runs as a plain function, its state survives from one call to the
// next, `commit()` runs its effect (the fetch of the changelog), and the
// handlers of the rendered tree are called as clicks would. The markup is
// read through renderToStaticMarkup.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({ language: "fr" }));
vi.mock("react-i18next", async () =>
(await import("../../test/i18nMock")).i18nMock({ language: () => ctx.language }),
);
import { renderToStaticMarkup } from "react-dom/server";
import { hookHarness } from "../../test/hookHarness";
import ChangelogContent, { CHANGELOG_NOTES_ID_PREFIX } from "./ChangelogContent";
const harness = hookHarness();
const FR = [
"# Journal des modifications",
"",
"## [Non publié]",
"",
"## [0.17.2] - 2026-10-18",
"",
"### Ajouté",
"",
"- **Import** : note-0172-a",
"",
"### Corrigé",
"",
"- note-0172-b",
"",
"## [0.17.1] - 2026-10-08",
"",
"### Modifié",
"",
"- note-0171",
"",
"## 0.3.2",
"",
"### Ajouté",
"",
"- note-032",
"",
].join("\n");
const EN = FR.replace("Journal des modifications", "Changelog")
.replace("[Non publié]", "[Unreleased]")
.replace("note-0171", "note-0171-en");
/** FR with content under its unreleased heading, as in a development build. */
const FR_DEV = FR.replace("## [Non publié]\n", "## [Non publié]\n\n### Modifié\n\n- note-dev\n");
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
interface FetchedFile {
text: () => Promise<string>;
}
let files: Record<string, string>;
/** The bundled files, `files` by URL; any other URL fails, as an unreadable file. */
const readFiles = async (url: string): Promise<FetchedFile> => {
if (!(url in files)) throw new Error(`unexpected fetch ${url}`);
return { text: async () => files[url] };
};
const fetchMock = vi.fn(readFiles);
/** Lets the fetch, `text()` and the state updates that follow them settle. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** One render of the mounted component. */
function render(installedVersion: string | null = "0.17.1"): ReactNode {
return harness.render(() => ChangelogContent({ installedVersion }) as ReactNode);
}
/** Mount, run the effect, let the file load, then render with the loaded state. */
async function mount(installedVersion: string | null = "0.17.1"): Promise<ReactNode> {
render(installedVersion);
harness.commit();
await settle();
return render(installedVersion);
}
/** The title button of `version`. */
function titleOf(tree: ReactNode, version: string): AnyElement {
const [button] = find(
tree,
(e) => e.type === "button" && e.props["aria-controls"] !== undefined && texts(e.props.children).includes(version),
);
expect(button, `title of ${version}`).toBeDefined();
return button;
}
/** The version titles, in order, with their state. */
function titles(tree: ReactNode): Array<[string, boolean]> {
return find(tree, (e) => e.type === "button" && e.props["aria-controls"] !== undefined).map((e) => [
texts(e.props.children).join(""),
e.props["aria-expanded"] as boolean,
]);
}
/** The expand-all button. */
function allButton(tree: ReactNode): AnyElement {
const [button] = find(tree, (e) => e.type === "button" && e.props["aria-controls"] === undefined);
expect(button, "expand-all button").toBeDefined();
return button;
}
const click = (button: AnyElement) => (button.props.onClick as () => void)();
beforeEach(() => {
harness.clear();
ctx.language = "fr";
files = { "/CHANGELOG.fr.md": FR, "/CHANGELOG.md": EN };
fetchMock.mockReset();
fetchMock.mockImplementation(readFiles);
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ChangelogContent — loading", () => {
it("reads the changelog of the app language", async () => {
await mount();
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.fr.md");
harness.clear();
ctx.language = "en";
fetchMock.mockClear();
await mount();
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.md");
});
it("shows the empty message until the file is read, and when it cannot be read", async () => {
expect(renderToStaticMarkup(render())).toContain("t(changelog.empty)");
harness.clear();
files = {};
const html = renderToStaticMarkup(await mount());
expect(html).toContain("t(changelog.empty)");
expect(html).not.toContain("aria-expanded");
});
});
describe("ChangelogContent — which versions open (#372)", () => {
it("opens on the installed version, every other version folded", async () => {
const tree = await mount("0.17.1");
expect(titles(tree)).toEqual([
["0.17.2", false],
["0.17.1", true],
["0.3.2", false],
]);
const html = renderToStaticMarkup(tree);
expect(html).toContain("note-0171");
expect(html).not.toContain("note-0172-a");
expect(html).not.toContain("note-032");
});
it("opens on the first published version when the installed version is unknown", async () => {
const tree = await mount(null);
expect(titles(tree)).toEqual([
["0.17.2", true],
["0.17.1", false],
["0.3.2", false],
]);
});
it("opens on the first published version when the installed one is not in the file", async () => {
const tree = await mount("0.18.0");
expect(titles(tree).filter(([, open]) => open)).toEqual([["0.17.2", true]]);
});
it("hides the empty unreleased heading", async () => {
const html = renderToStaticMarkup(await mount());
expect(html).not.toContain("Non publié");
});
it("keeps an unreleased heading with content, folded, before the versions", async () => {
files["/CHANGELOG.fr.md"] = FR_DEV;
const tree = await mount("0.17.2");
expect(titles(tree)).toEqual([
["Non publié", false],
["0.17.2", true],
["0.17.1", false],
["0.3.2", false],
]);
expect(renderToStaticMarkup(tree)).not.toContain("note-dev");
});
it("shows the notes of an open version, sections and items, without bold marks", async () => {
const html = renderToStaticMarkup(await mount("0.17.2"));
expect(html).toContain(">Ajouté</h4>");
expect(html).toContain(">Corrigé</h4>");
expect(html).toContain("Import : note-0172-a");
expect(html).not.toContain("**Import**");
expect(html).toContain("note-0172-b");
});
});
describe("ChangelogContent — folding by hand", () => {
it("unfolds a folded version and folds it again on its title", async () => {
let tree: ReactNode = await mount("0.17.1");
click(titleOf(tree, "0.3.2"));
tree = render();
expect(titles(tree)).toEqual([
["0.17.2", false],
["0.17.1", true],
["0.3.2", true],
]);
expect(renderToStaticMarkup(tree)).toContain("note-032");
click(titleOf(tree, "0.3.2"));
tree = render();
expect(titleOf(tree, "0.3.2").props["aria-expanded"]).toBe(false);
expect(renderToStaticMarkup(tree)).not.toContain("note-032");
});
it("folds the installed version on its title", async () => {
let tree: ReactNode = await mount("0.17.1");
click(titleOf(tree, "0.17.1"));
tree = render();
expect(titles(tree).every(([, open]) => !open)).toBe(true);
});
it("unfolds everything with « Expand all », then folds everything with « Collapse all »", async () => {
let tree: ReactNode = await mount("0.17.1");
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
click(allButton(tree));
tree = render();
expect(titles(tree).every(([, open]) => open)).toBe(true);
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
const html = renderToStaticMarkup(tree);
for (const note of ["note-0172-a", "note-0171", "note-032"]) expect(html).toContain(note);
click(allButton(tree));
tree = render();
expect(titles(tree).every(([, open]) => !open)).toBe(true);
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
});
it("offers « Collapse all » once every version is unfolded by hand", async () => {
let tree: ReactNode = await mount("0.17.1");
click(titleOf(tree, "0.17.2"));
tree = render();
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
click(titleOf(tree, "0.3.2"));
tree = render();
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
});
});
describe("ChangelogContent — accessibility", () => {
it("makes each title a button inside its heading, with its state and the notes it controls", async () => {
const html = renderToStaticMarkup(await mount("0.17.1"));
// Heading > button with aria-expanded and aria-controls, chevron, version.
expect(html).toMatch(
/<h3[^>]*><button type="button" aria-expanded="true" aria-controls="changelog-notes-1" title="t\(changelog\.hideNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.1<\/button><\/h3>/,
);
expect(html).toMatch(
/<h3[^>]*><button type="button" aria-expanded="false" aria-controls="changelog-notes-0" title="t\(changelog\.showNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.2<\/button><\/h3>/,
);
// Every id an aria-controls names is in the page, folded or not.
const controlled = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((m) => m[1]);
expect(controlled).toEqual([0, 1, 2].map((i) => `${CHANGELOG_NOTES_ID_PREFIX}${i}`));
for (const id of controlled) expect(html).toContain(`id="${id}"`);
});
it("names the expand-all button through t, never in hard text", async () => {
const html = renderToStaticMarkup(await mount("0.17.1"));
expect(html).toMatch(/<button type="button"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>t\(changelog\.expandAll\)<\/button>/);
});
});
describe("ChangelogContent — language switch", () => {
it("reloads the file of the new language and opens the installed version again", async () => {
let tree: ReactNode = await mount("0.17.1");
click(allButton(tree));
ctx.language = "en";
render();
harness.commit();
await settle();
tree = render();
expect(fetchMock).toHaveBeenLastCalledWith("/CHANGELOG.md");
expect(titles(tree)).toEqual([
["0.17.2", false],
["0.17.1", true],
["0.3.2", false],
]);
expect(renderToStaticMarkup(tree)).toContain("note-0171-en");
});
it("never lets a late answer of the previous language replace the current one", async () => {
const pending: Array<{ url: string; resolve: (text: string) => void }> = [];
fetchMock.mockImplementation(
(url: string) =>
new Promise<FetchedFile>((resolveFetch) => {
pending.push({ url, resolve: (text) => resolveFetch({ text: async () => text }) });
}),
);
render("0.17.1");
harness.commit(); // asks for the French file
ctx.language = "en";
render("0.17.1");
harness.commit(); // cancels the French one, asks for the English file
expect(pending.map((p) => p.url)).toEqual(["/CHANGELOG.fr.md", "/CHANGELOG.md"]);
pending[1].resolve(EN);
await settle();
pending[0].resolve(FR);
await settle();
const html = renderToStaticMarkup(render("0.17.1"));
expect(html).toContain("note-0171-en");
expect(html).not.toMatch(/note-0171(?!-en)/);
});
});

View file

@ -1,57 +1,52 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import {
allVersionsExpanded,
initiallyExpandedVersions,
parseChangelog,
toggleAllVersions,
toggleVersion,
visibleChangelogEntries,
type ChangelogEntry,
} from "../../utils/changelog";
interface ChangelogEntry {
version: string;
sections: { heading: string; items: string[] }[];
/** Prefix of the id of each version's notes, which its title names in `aria-controls`. */
export const CHANGELOG_NOTES_ID_PREFIX = "changelog-notes-";
interface ChangelogContentProps {
/** The running app's version (`getVersion()`), `null` when it could not be read. */
installedVersion: string | null;
}
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogContent() {
export default function ChangelogContent({ installedVersion }: ChangelogContentProps) {
const { t, i18n } = useTranslation();
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
// Versions whose notes show. Seeded by the expansion rule at each load of
// the file, then changed by the titles and the expand-all button. In
// memory only: the history opens on the installed version every time (#372).
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
useEffect(() => {
// A file of the previous language that answers late must not replace
// the one of the current language.
let cancelled = false;
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => setEntries(parseChangelog(text)))
.catch(() => setEntries([]));
}, [i18n.language]);
.then((text) => {
if (cancelled) return;
const visible = visibleChangelogEntries(parseChangelog(text));
setEntries(visible);
setExpanded(initiallyExpandedVersions(visible, installedVersion));
})
.catch(() => {
if (!cancelled) setEntries([]);
});
return () => {
cancelled = true;
};
}, [i18n.language, installedVersion]);
if (entries.length === 0) {
return (
@ -59,14 +54,53 @@ export default function ChangelogContent() {
);
}
const allExpanded = allVersionsExpanded(entries, expanded);
return (
<div className="space-y-3">
<div className="flex justify-end">
<button
type="button"
onClick={() => setExpanded((current) => toggleAllVersions(entries, current))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? (
<ChevronsDownUp size={13} aria-hidden="true" />
) : (
<ChevronsUpDown size={13} aria-hidden="true" />
)}
{allExpanded ? t("changelog.collapseAll") : t("changelog.expandAll")}
</button>
</div>
{entries.map((entry, index) => {
const open = expanded.has(entry.version);
const notesId = `${CHANGELOG_NOTES_ID_PREFIX}${index}`;
return (
<div className="space-y-6">
{entries.map((entry) => (
<div
key={entry.version}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
className="bg-[var(--card)] border border-[var(--border)] rounded-xl"
>
<h3 className="text-lg font-semibold">{entry.version}</h3>
<h3 className="text-lg font-semibold">
<button
type="button"
aria-expanded={open}
aria-controls={notesId}
title={open ? t("changelog.hideNotes") : t("changelog.showNotes")}
onClick={() => setExpanded((current) => toggleVersion(current, entry.version))}
className="flex items-center gap-2 w-full px-6 py-3 text-left hover:opacity-80 transition-opacity"
>
{open ? (
<ChevronDown size={18} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronRight size={18} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
)}
{entry.version}
</button>
</h3>
{/* Always in the page, so `aria-controls` names an element even while folded. */}
<div id={notesId}>
{open && (
<div className="px-6 pb-6 space-y-3">
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h4 className="text-sm font-semibold text-[var(--primary)]">
@ -86,7 +120,11 @@ export default function ChangelogContent() {
</div>
))}
</div>
))}
)}
</div>
</div>
);
})}
</div>
);
}

View file

@ -5,7 +5,11 @@
"changelog": {
"title": "Version History",
"description": "View what's new and fixed in each version",
"empty": "No entries available"
"empty": "No entries available",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"showNotes": "Show this version's notes",
"hideNotes": "Hide this version's notes"
},
"nav": {
"dashboard": "Dashboard",

View file

@ -5,7 +5,11 @@
"changelog": {
"title": "Historique des versions",
"description": "Consultez les nouveautés et corrections de chaque version",
"empty": "Aucune entrée disponible"
"empty": "Aucune entrée disponible",
"expandAll": "Tout déplier",
"collapseAll": "Tout replier",
"showNotes": "Afficher les notes de cette version",
"hideNotes": "Masquer les notes de cette version"
},
"nav": {
"dashboard": "Tableau de bord",

View file

@ -0,0 +1,118 @@
// SystemsSettingsPage — the Journal before the version history, and the
// history mounted once the installed version is known (#372).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
// page runs as a plain function, `commit()` runs its effect (getVersion), and
// the next render reads what the effect stored. The cards are stubs: only
// where the page puts them, and what it hands the history, are under test.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("react-router-dom", async () => {
const { createElement } = await vi.importActual<typeof import("react")>("react");
return {
Link: ({ to, className, children }: { to: string; className?: string; children?: ReactNode }) =>
createElement("a", { href: to, className }, children),
};
});
vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn() }));
vi.mock("../../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
vi.mock("../../components/settings/UpdateCard", () => ({ default: () => "[UpdateCard]" }));
vi.mock("../../components/settings/LogViewerCard", () => ({ default: () => "[LogViewerCard]" }));
vi.mock("../../components/settings/ChangelogContent", () => ({ default: () => "[ChangelogContent]" }));
import { renderToStaticMarkup } from "react-dom/server";
import { getVersion } from "@tauri-apps/api/app";
import { hookHarness } from "../../test/hookHarness";
import { logWarn } from "../../services/logService";
import ChangelogContent from "../../components/settings/ChangelogContent";
import SystemsSettingsPage from "./SystemsSettingsPage";
const harness = hookHarness();
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
/** One render of the mounted page. */
const render = (): ReactNode => harness.render(() => SystemsSettingsPage() as ReactNode);
/** Lets getVersion() and the state update that follows it settle. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const history = (page: ReactNode) => find(page, (e) => e.type === ChangelogContent);
beforeEach(() => {
harness.clear();
vi.mocked(getVersion).mockReset();
vi.mocked(logWarn).mockReset();
});
describe("SystemsSettingsPage — order of the sections (#372)", () => {
it("renders Version, Update, the Journal, then the version history and the data notice", () => {
vi.mocked(getVersion).mockResolvedValue("0.17.2");
harness.slots[0] = "0.17.2"; // the version, already read
const html = renderToStaticMarkup(render());
const at = (marker: string) => {
const index = html.indexOf(marker);
expect(index, marker).toBeGreaterThanOrEqual(0);
return index;
};
const order = [
at("t(settings.systems.sections.version)"),
at("t(settings.systems.sections.update)"),
at("[UpdateCard]"),
at("t(settings.systems.sections.logs)"),
at("[LogViewerCard]"),
at("t(settings.systems.sections.changelog)"),
at("[ChangelogContent]"),
at("t(settings.dataSafeNotice)"),
];
expect(order).toEqual([...order].sort((a, b) => a - b));
});
});
describe("SystemsSettingsPage — the version history waits for the version (#372)", () => {
it("keeps the history heading but mounts no history while getVersion() runs", () => {
vi.mocked(getVersion).mockReturnValue(new Promise(() => {}));
let page = render();
harness.commit();
page = render();
expect(history(page)).toHaveLength(0);
expect(renderToStaticMarkup(page)).toContain("t(settings.systems.sections.changelog)");
});
it("mounts the history with the installed version once it is read", async () => {
vi.mocked(getVersion).mockResolvedValue("0.17.2");
expect(history(render())).toHaveLength(0);
harness.commit();
await settle();
const page = render();
const [mounted] = history(page);
expect(mounted.props.installedVersion).toBe("0.17.2");
expect(renderToStaticMarkup(page)).toContain("t(settings.version[version=0.17.2])");
expect(logWarn).not.toHaveBeenCalled();
});
it("mounts the history with an unknown version when getVersion() fails, and says so in the Journal", async () => {
vi.mocked(getVersion).mockRejectedValue(new Error("ipc down"));
render();
harness.commit();
await settle();
const page = render();
const [mounted] = history(page);
expect(mounted.props.installedVersion).toBeNull();
expect(renderToStaticMarkup(page)).toContain("t(settings.version[version=])");
expect(logWarn).toHaveBeenCalledTimes(1);
expect(vi.mocked(logWarn).mock.calls[0][0]).toBe(
"App version unavailable; the version history opens on the latest release",
);
});
});

View file

@ -4,16 +4,23 @@ import { Link } from "react-router-dom";
import { ArrowLeft, ShieldCheck } from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { APP_NAME } from "../../shared/constants";
import { logWarn } from "../../services/logService";
import UpdateCard from "../../components/settings/UpdateCard";
import ChangelogContent from "../../components/settings/ChangelogContent";
import LogViewerCard from "../../components/settings/LogViewerCard";
export default function SystemsSettingsPage() {
const { t } = useTranslation();
const [version, setVersion] = useState("");
// `undefined` while getVersion() runs, `null` when it failed.
const [version, setVersion] = useState<string | null | undefined>(undefined);
useEffect(() => {
getVersion().then(setVersion);
getVersion()
.then(setVersion)
.catch((error) => {
logWarn("App version unavailable; the version history opens on the latest release", String(error));
setVersion(null);
});
}, []);
return (
@ -40,7 +47,7 @@ export default function SystemsSettingsPage() {
<div>
<h3 className="text-lg font-semibold">{APP_NAME}</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.version", { version })}
{t("settings.version", { version: version ?? "" })}
</p>
</div>
</div>
@ -54,13 +61,7 @@ export default function SystemsSettingsPage() {
<UpdateCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.changelog")}
</h2>
<ChangelogContent />
</section>
{/* The Journal comes before the version history, which grows at each release (#372). */}
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.logs")}
@ -68,6 +69,14 @@ export default function SystemsSettingsPage() {
<LogViewerCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.changelog")}
</h2>
{/* Mounted once the version is settled: the history opens on it (#372). */}
{version !== undefined && <ChangelogContent installedVersion={version} />}
</section>
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p>

231
src/utils/changelog.test.ts Normal file
View file

@ -0,0 +1,231 @@
// Version history of Settings → System (#372): the parser and the rule that
// decides which versions show their notes when the history opens.
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
allVersionsExpanded,
initiallyExpandedVersions,
isPublishedVersion,
parseChangelog,
toggleAllVersions,
toggleVersion,
visibleChangelogEntries,
type ChangelogEntry,
} from "./changelog";
const ROOT = resolve(import.meta.dirname, "../..");
/** An entry with one section per heading, each with its items. */
function entry(version: string, sections: Record<string, string[]> = {}): ChangelogEntry {
return {
version,
sections: Object.entries(sections).map(([heading, items]) => ({ heading, items })),
};
}
const sorted = (set: Set<string>) => [...set].sort();
describe("parseChangelog", () => {
it("reads bracketed headings with a date, and headings without brackets", () => {
const entries = parseChangelog(
[
"# Changelog",
"",
"## [0.17.2] - 2026-10-18",
"",
"### Added",
"",
"- A",
"",
"## 0.3.2",
"",
"### Fixed",
"",
"- B",
].join("\n"),
);
expect(entries.map((e) => e.version)).toEqual(["0.17.2", "0.3.2"]);
});
it("gives each version its sections in order, with their items, bold marks kept", () => {
const [first, second] = parseChangelog(
[
"## [0.17.2] - 2026-10-18",
"### Added",
"- **Import**: A",
"- B",
"### Fixed",
"- C",
"## [0.17.1] - 2026-10-08",
"### Changed",
"- D",
].join("\n"),
);
expect(first).toEqual(
entry("0.17.2", { Added: ["**Import**: A", "B"], Fixed: ["C"] }),
);
expect(second).toEqual(entry("0.17.1", { Changed: ["D"] }));
});
it("reads an empty Unreleased heading as an entry without sections, in both languages", () => {
expect(parseChangelog("# Changelog\n\n## [Unreleased]\n\n## [0.17.2] - 2026-10-18\n")).toEqual([
entry("Unreleased"),
entry("0.17.2"),
]);
expect(parseChangelog("## [Non publié]\n\n## [0.17.2] - 2026-10-18\n")[0]).toEqual(entry("Non publié"));
});
it("ignores text outside of a section, a section before any version, and Windows line ends", () => {
const entries = parseChangelog(
[
"### Orphan",
"- before any version",
"## [1.0.0]",
"Some paragraph.",
"- item without a section",
"### Added",
"- kept",
].join("\r\n"),
);
expect(entries).toEqual([entry("1.0.0", { Added: ["kept"] })]);
});
it("reads nothing from text without a version heading", () => {
expect(parseChangelog("")).toEqual([]);
expect(parseChangelog("# Changelog\n\nNothing yet.\n")).toEqual([]);
});
});
describe("isPublishedVersion", () => {
it("is true for a version number, false for the unreleased heading of either language", () => {
expect(isPublishedVersion("0.17.2")).toBe(true);
expect(isPublishedVersion("0.2.10")).toBe(true);
expect(isPublishedVersion("Unreleased")).toBe(false);
expect(isPublishedVersion("Non publié")).toBe(false);
});
});
describe("visibleChangelogEntries", () => {
it("hides an empty Unreleased heading, in both languages", () => {
const published = entry("0.17.2", { Added: ["A"] });
expect(visibleChangelogEntries([entry("Unreleased"), published])).toEqual([published]);
expect(visibleChangelogEntries([entry("Non publié"), published])).toEqual([published]);
});
it("hides an Unreleased heading whose sections have no item", () => {
const published = entry("0.17.2", { Added: ["A"] });
expect(visibleChangelogEntries([entry("Unreleased", { Added: [] }), published])).toEqual([published]);
});
it("keeps an Unreleased heading with content (development build)", () => {
const unreleased = entry("Unreleased", { Changed: ["Work in progress"] });
const published = entry("0.17.2", { Added: ["A"] });
expect(visibleChangelogEntries([unreleased, published])).toEqual([unreleased, published]);
});
it("keeps a published version even without any item", () => {
const bare = entry("0.2.2");
expect(visibleChangelogEntries([entry("0.2.3", { Added: ["A"] }), bare])).toContain(bare);
});
});
describe("initiallyExpandedVersions", () => {
const entries = [
entry("0.17.2", { Added: ["A"] }),
entry("0.17.1", { Changed: ["B"] }),
entry("0.3.2", { Fixed: ["C"] }),
];
it("unfolds the installed version alone", () => {
expect(sorted(initiallyExpandedVersions(entries, "0.17.1"))).toEqual(["0.17.1"]);
expect(sorted(initiallyExpandedVersions(entries, "0.3.2"))).toEqual(["0.3.2"]);
});
it("unfolds the first published version when the installed one is absent from the changelog", () => {
expect(sorted(initiallyExpandedVersions(entries, "0.18.0"))).toEqual(["0.17.2"]);
});
it("unfolds the first published version when the installed version is unknown", () => {
expect(sorted(initiallyExpandedVersions(entries, null))).toEqual(["0.17.2"]);
});
it("never takes an Unreleased heading with content for the first published version", () => {
const withUnreleased = [entry("Non publié", { Modifié: ["En cours"] }), ...entries];
expect(sorted(initiallyExpandedVersions(withUnreleased, null))).toEqual(["0.17.2"]);
expect(sorted(initiallyExpandedVersions(withUnreleased, "0.18.0"))).toEqual(["0.17.2"]);
expect(sorted(initiallyExpandedVersions(withUnreleased, "0.17.1"))).toEqual(["0.17.1"]);
});
it("unfolds nothing without any published version", () => {
expect(sorted(initiallyExpandedVersions([], "0.17.2"))).toEqual([]);
expect(sorted(initiallyExpandedVersions([entry("Unreleased", { Added: ["A"] })], null))).toEqual([]);
});
});
describe("toggleVersion", () => {
it("unfolds a folded version and folds an unfolded one, without touching the others or its input", () => {
const expanded = new Set(["0.17.2"]);
expect(sorted(toggleVersion(expanded, "0.17.1"))).toEqual(["0.17.1", "0.17.2"]);
expect(sorted(toggleVersion(expanded, "0.17.2"))).toEqual([]);
expect(sorted(expanded)).toEqual(["0.17.2"]);
});
});
describe("allVersionsExpanded and toggleAllVersions (the expand-all button)", () => {
const entries = [entry("0.17.2"), entry("0.17.1"), entry("0.3.2")];
it("unfolds every version while one or more is folded", () => {
expect(allVersionsExpanded(entries, new Set(["0.17.2"]))).toBe(false);
expect(sorted(toggleAllVersions(entries, new Set(["0.17.2"])))).toEqual(["0.17.1", "0.17.2", "0.3.2"]);
expect(sorted(toggleAllVersions(entries, new Set()))).toEqual(["0.17.1", "0.17.2", "0.3.2"]);
});
it("folds every version once all are unfolded", () => {
const all = new Set(["0.17.2", "0.17.1", "0.3.2"]);
expect(allVersionsExpanded(entries, all)).toBe(true);
expect(sorted(toggleAllVersions(entries, all))).toEqual([]);
});
it("is not all unfolded without any entry", () => {
expect(allVersionsExpanded([], new Set())).toBe(false);
});
});
describe("the real changelogs", () => {
// The app bundles both files (vite.config.ts copies them to public/), and
// getVersion() reads the version of tauri.conf.json. /release bumps that
// version and adds its heading to both files in the same commit, so the
// installed version is always found, in both languages.
const installed = (
JSON.parse(readFileSync(resolve(ROOT, "src-tauri/tauri.conf.json"), "utf-8")) as { version: string }
).version;
const files = {
en: parseChangelog(readFileSync(resolve(ROOT, "CHANGELOG.md"), "utf-8")),
fr: parseChangelog(readFileSync(resolve(ROOT, "CHANGELOG.fr.md"), "utf-8")),
};
it("read the unreleased heading first, then only version numbers, with and without brackets", () => {
for (const [lng, entries] of Object.entries(files)) {
const [unreleased, ...published] = entries;
expect(isPublishedVersion(unreleased.version), `${lng}: ${unreleased.version}`).toBe(false);
const notVersions = published.filter((e) => !isPublishedVersion(e.version)).map((e) => e.version);
expect(notVersions, lng).toEqual([]);
// Headings without brackets (`## 0.3.2`) are read too.
expect(published.map((e) => e.version), lng).toContain("0.3.2");
}
});
it("list the same published versions in both languages", () => {
const published = (entries: ChangelogEntry[]) =>
entries.filter((e) => isPublishedVersion(e.version)).map((e) => e.version);
expect(published(files.fr)).toEqual(published(files.en));
});
it("open on the installed version, in both languages", () => {
for (const [lng, entries] of Object.entries(files)) {
const visible = visibleChangelogEntries(entries);
expect(sorted(initiallyExpandedVersions(visible, installed)), lng).toEqual([installed]);
}
});
});

119
src/utils/changelog.ts Normal file
View file

@ -0,0 +1,119 @@
// Version history of Settings → System (#372): the parser of the bundled
// CHANGELOG files and the rule that decides which versions show their notes
// when the history opens. Pure: no fetch, no React. `UpdateCard` reads the
// notes of an available update on its own and does not use this module.
/** One `### ` heading of a version and its `- ` items. */
export interface ChangelogSection {
heading: string;
items: string[];
}
/** One `## ` heading of the changelog and its sections. */
export interface ChangelogEntry {
version: string;
sections: ChangelogSection[];
}
/**
* Parses a Keep a Changelog file, newest version first: one entry per `## `
* heading, bracketed (`## [0.17.2] - 2026-10-18` gives `0.17.2`) or not
* (`## 0.3.2`), each with its `### ` sections and their `- ` items. A line
* outside of a section is ignored.
*/
export function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: ChangelogSection | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
/**
* A released version (`0.17.2`), as opposed to the heading of what is not
* released yet, `[Unreleased]` in English and `[Non publié]` in French. The
* shape decides, not the label, so both languages read the same.
*/
export function isPublishedVersion(version: string): boolean {
return /^\d+\.\d+/.test(version);
}
/** No item under any of its sections: nothing to read under the title. */
function hasNoItem(entry: ChangelogEntry): boolean {
return entry.sections.every((section) => section.items.length === 0);
}
/**
* The entries the history shows: every one, except an unreleased heading
* with nothing under it, as right after a release. An unreleased heading
* with content (a development build) stays, and a published version always
* stays: the history is complete.
*/
export function visibleChangelogEntries(entries: ChangelogEntry[]): ChangelogEntry[] {
return entries.filter((entry) => isPublishedVersion(entry.version) || !hasNoItem(entry));
}
/**
* The versions whose notes show when the history opens: the installed
* version, or, when it is unknown (`null`) or absent from the changelog, the
* first published version, the newest. Every other version starts folded.
*/
export function initiallyExpandedVersions(
entries: ChangelogEntry[],
installedVersion: string | null,
): Set<string> {
const installed =
installedVersion === null ? undefined : entries.find((entry) => entry.version === installedVersion);
const opened = installed ?? entries.find((entry) => isPublishedVersion(entry.version));
return new Set(opened ? [opened.version] : []);
}
/** `expanded` with `version` unfolded when it was folded, folded when it was unfolded. */
export function toggleVersion(expanded: ReadonlySet<string>, version: string): Set<string> {
const next = new Set(expanded);
if (next.has(version)) {
next.delete(version);
} else {
next.add(version);
}
return next;
}
/** Every entry shows its notes: the expand-all button then folds them all. */
export function allVersionsExpanded(entries: ChangelogEntry[], expanded: ReadonlySet<string>): boolean {
return entries.length > 0 && entries.every((entry) => expanded.has(entry.version));
}
/** The expand-all button: folds every entry when all are unfolded, unfolds them all otherwise. */
export function toggleAllVersions(entries: ChangelogEntry[], expanded: ReadonlySet<string>): Set<string> {
return allVersionsExpanded(entries, expanded)
? new Set()
: new Set(entries.map((entry) => entry.version));
}