mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
feat(app): read the changelog in the app instead of the browser (#4576)
What's new opens an adaptive sheet — bottom sheet on compact, centred card on desktop — that fetches CHANGELOG.md from the repository and renders it with the app's own chrome. The desktop update callout, the sidebar help menu, and a new row in Settings → About all open it; the sheet header links out to paseo.sh/changelog. The parser commits to two facts only: `##` starts a release and `###` starts a section. Section titles are read from the document rather than matched against a list, and everything under a section passes through verbatim to the Markdown renderer, so a future callout, block quote, code fence or image needs no parser change. Fences are tracked because a fenced sample is the one thing that can fool a line scan. The document is fetched rather than bundled because the app has to describe a release that shipped after it was built, which is the update callout's whole purpose. It goes straight to the repository rather than through a host: the notes describe the app, and routing them through a daemon would make them depend on which host happens to be connected. Nothing is fetched or parsed until the sheet opens. The latest release renders as soon as there is one and the rest of the first page follows in a transition, so React slices that work around the opening animation instead of committing hundreds of list items at once.
This commit is contained in:
@@ -486,6 +486,8 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
|
||||
|
||||
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
|
||||
|
||||
const DISCORD_DESTINATION =
|
||||
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
|
||||
@@ -41,7 +42,7 @@ async function expectExternalPage(
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||
test("opens troubleshooting and support destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
@@ -62,18 +63,60 @@ test("opens troubleshooting, support, and release destinations", async ({ page }
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
|
||||
await test.step("opens support and release pages", async () => {
|
||||
await test.step("opens support pages", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders the changelog in the app and links the website", async ({ page }) => {
|
||||
// A callout, a section name the app has never seen, and a fenced sample whose
|
||||
// contents look like a release heading.
|
||||
await serveChangelog(page, [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 9.1.0 - 2026-03-04",
|
||||
"",
|
||||
"Headline release note.",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"",
|
||||
"### Sparkles",
|
||||
"",
|
||||
"- Added a brand new thing",
|
||||
"",
|
||||
"```md",
|
||||
"## 0.0.0 - 1999-01-01",
|
||||
"```",
|
||||
"",
|
||||
"## 9.0.0 - 2026-02-01",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed an older thing",
|
||||
"",
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const sheet = await openWhatsNew(page);
|
||||
const latest = release(sheet, "9.1.0");
|
||||
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Headline release note.")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(release(sheet, "9.0.0")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
|
||||
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
|
||||
await closeSheet(page, "changelog-sheet");
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const CHANGELOG_SOURCE_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
/**
|
||||
* Serves a changelog to the app instead of the repository's.
|
||||
*
|
||||
* The sheet renders whatever the document contains, so a fixture is how a test
|
||||
* states which authoring shapes it cares about.
|
||||
*/
|
||||
export async function serveChangelog(page: Page, lines: string[]): Promise<void> {
|
||||
await page.route(CHANGELOG_SOURCE_URL, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "text/plain", body: lines.join("\n") }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openWhatsNew(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-changelog").click();
|
||||
|
||||
const sheet = page.getByTestId("changelog-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export function release(sheet: Locator, version: string): Locator {
|
||||
return sheet.getByTestId(`changelog-release-${version}`);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { ChangelogHost } from "@/changelog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
@@ -619,6 +620,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<ChangelogHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useChangelogStore } from "./internal/changelog-store";
|
||||
import { ChangelogSheet } from "./internal/changelog-sheet";
|
||||
|
||||
/** Mounted once by the app shell so any surface can call `openChangelog()`. */
|
||||
export function ChangelogHost() {
|
||||
const visible = useChangelogStore((state) => state.visible);
|
||||
const close = useChangelogStore((state) => state.close);
|
||||
|
||||
return <ChangelogSheet visible={visible} onClose={close} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* What's new: the release notes, read from the repository's CHANGELOG.md and
|
||||
* rendered in the app instead of sending the reader to a browser.
|
||||
*
|
||||
* Two things leave this module — the host the app shell mounts, and the command
|
||||
* every entry point calls. Where the document comes from, how it is parsed, and
|
||||
* how much of it renders at once stay inside; import from `@/changelog`, never
|
||||
* a path within it.
|
||||
*/
|
||||
export { ChangelogHost } from "./changelog-host";
|
||||
export { openChangelog } from "./open-changelog";
|
||||
@@ -0,0 +1,241 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ExternalLink, Gift } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useChangelog, type ChangelogState } from "./changelog-source";
|
||||
import { useRevealedReleases } from "./use-revealed-releases";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
|
||||
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
iconButtonChromeStyle({ size: "large", state });
|
||||
|
||||
interface ChangelogSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, reload } = useChangelog(visible);
|
||||
const { count, showMore } = useRevealedReleases(visible && state.status === "ready");
|
||||
|
||||
const handleOpenWebsite = useCallback(() => {
|
||||
void openExternalUrl(WEBSITE_CHANGELOG_URL);
|
||||
}, []);
|
||||
|
||||
const header: SheetHeader = useMemo(
|
||||
() => ({
|
||||
title: t("changelog.title"),
|
||||
leading: sheetLeadingIcon,
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={handleOpenWebsite}
|
||||
hitSlop={8}
|
||||
style={websiteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("changelog.openWebsite")}
|
||||
testID="changelog-open-website"
|
||||
>
|
||||
<ThemedExternalLink
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
uniProps={mutedColorMapping}
|
||||
/>
|
||||
</Pressable>
|
||||
),
|
||||
}),
|
||||
[handleOpenWebsite, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
desktopMaxWidth={620}
|
||||
desktopHeight="85%"
|
||||
testID="changelog-sheet"
|
||||
>
|
||||
<ChangelogBody state={state} shownReleases={count} onShowMore={showMore} onRetry={reload} />
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const SNAP_POINTS = ["85%", "95%"];
|
||||
|
||||
interface ChangelogBodyProps {
|
||||
state: ChangelogState;
|
||||
shownReleases: number;
|
||||
onShowMore: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const appVersion = useMemo(() => resolveAppVersion()?.replace(/^v/i, "") ?? null, []);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Alert
|
||||
variant="error"
|
||||
title={t("changelog.error.title")}
|
||||
description={t("changelog.error.description")}
|
||||
testID="changelog-error"
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleReleases = state.releases.slice(0, shownReleases);
|
||||
|
||||
return (
|
||||
<View style={styles.releaseList}>
|
||||
{visibleReleases.map((release) => (
|
||||
<ReleaseView
|
||||
key={`${release.version}:${release.date}`}
|
||||
release={release}
|
||||
isCurrent={release.version === appVersion}
|
||||
/>
|
||||
))}
|
||||
{state.releases.length > visibleReleases.length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={onShowMore}
|
||||
style={styles.showMore}
|
||||
testID="changelog-show-more"
|
||||
>
|
||||
{t("changelog.showMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReleaseViewProps {
|
||||
release: ChangelogRelease;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const date = formatChangelogDate(release.date);
|
||||
|
||||
return (
|
||||
<View style={styles.release} testID={`changelog-release-${release.version}`}>
|
||||
<View style={styles.releaseHeading}>
|
||||
<Text style={styles.version}>{release.version}</Text>
|
||||
{isCurrent ? <StatusBadge label={t("changelog.installed")} /> : null}
|
||||
<View style={styles.headingSpacer} />
|
||||
{date ? <Text style={styles.date}>{date}</Text> : null}
|
||||
</View>
|
||||
{keyChangelogSections(release.sections).map(({ key, section }) => (
|
||||
<View key={key} style={styles.section}>
|
||||
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
|
||||
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** A release can repeat a section title; the occurrence keeps the key stable. */
|
||||
function keyChangelogSections(
|
||||
sections: readonly ChangelogSection[],
|
||||
): { key: string; section: ChangelogSection }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return sections.map((section) => {
|
||||
const title = section.title ?? "";
|
||||
const occurrence = seen.get(title) ?? 0;
|
||||
seen.set(title, occurrence + 1);
|
||||
return { key: `${title}:${occurrence}`, section };
|
||||
});
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
minHeight: 160,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
releaseList: {
|
||||
gap: theme.spacing[8],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
release: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
releaseHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headingSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
version: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
showMore: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { parseChangelog, type ChangelogRelease } from "./parse-changelog";
|
||||
|
||||
const CHANGELOG_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
export type ChangelogState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; releases: ChangelogRelease[] }
|
||||
| { status: "error" };
|
||||
|
||||
// Survives close/reopen so the second look paints without a spinner. The raw
|
||||
// text is kept alongside the releases so an unchanged revalidation can be
|
||||
// dropped: handing back an equal-but-new array would re-render every release.
|
||||
let cached: { markdown: string; releases: ChangelogRelease[] } | null = null;
|
||||
|
||||
export interface Changelog {
|
||||
state: ChangelogState;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the changelog from the repository the app was built from.
|
||||
*
|
||||
* The daemon is not involved: the changelog describes the app, a phone reaching
|
||||
* a relay already has internet, and going through a host would make the notes
|
||||
* depend on which host happens to be connected.
|
||||
*
|
||||
* Every open refetches, because the whole point of opening it is a release that
|
||||
* shipped after this app started. A previous result stays on screen while that
|
||||
* happens, and survives a failed revalidation.
|
||||
*/
|
||||
export function useChangelog(enabled: boolean): Changelog {
|
||||
const [state, setState] = useState<ChangelogState>(readCache);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setState(readCache());
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(CHANGELOG_URL, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Changelog request failed: ${response.status}`);
|
||||
const markdown = await response.text();
|
||||
if (cached?.markdown === markdown) return;
|
||||
const releases = parseChangelog(markdown);
|
||||
if (releases.length === 0) throw new Error("Changelog has no releases");
|
||||
cached = { markdown, releases };
|
||||
setState({ status: "ready", releases });
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
if (cached) return;
|
||||
setState({ status: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [enabled, attempt]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
cached = null;
|
||||
setAttempt((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
return { state, reload };
|
||||
}
|
||||
|
||||
function readCache(): ChangelogState {
|
||||
return cached ? { status: "ready", releases: cached.releases } : { status: "loading" };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ChangelogVisibility {
|
||||
visible: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useChangelogStore = create<ChangelogVisibility>((set) => ({
|
||||
visible: false,
|
||||
open: () => set({ visible: true }),
|
||||
close: () => set({ visible: false }),
|
||||
}));
|
||||
@@ -0,0 +1,301 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
parseChangelog,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
function bodies(markdown: string, releaseIndex = 0): string[] {
|
||||
return parseChangelog(markdown)[releaseIndex].sections.map(sectionBody);
|
||||
}
|
||||
|
||||
function sectionBody(section: ChangelogSection): string {
|
||||
return section.body;
|
||||
}
|
||||
|
||||
function sectionTitle(section: ChangelogSection): string | null {
|
||||
return section.title;
|
||||
}
|
||||
|
||||
function releaseDate(release: ChangelogRelease): string {
|
||||
return release.date;
|
||||
}
|
||||
|
||||
function authoredLines(release: ChangelogRelease): string[] {
|
||||
return release.sections.flatMap(sectionLines);
|
||||
}
|
||||
|
||||
function sectionLines(section: ChangelogSection): string[] {
|
||||
return [...(section.title ? [`### ${section.title}`] : []), ...section.body.split("\n")];
|
||||
}
|
||||
|
||||
function isNotBlank(line: string): boolean {
|
||||
return line.trim().length > 0;
|
||||
}
|
||||
|
||||
describe("parseChangelog", () => {
|
||||
it("reads the shape the changelog is authored in today", () => {
|
||||
const releases = parseChangelog(
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 0.8.0-beta.1 - 2026-09-08",
|
||||
"",
|
||||
"This release extends what plugins can do.",
|
||||
"",
|
||||
"### Plugins",
|
||||
"",
|
||||
"- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed the mobile terminal keyboard closing",
|
||||
"",
|
||||
"## 0.7.2 - 2026-09-02",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a thing",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(releases).toEqual([
|
||||
{
|
||||
version: "0.8.0-beta.1",
|
||||
date: "2026-09-08",
|
||||
sections: [
|
||||
{ title: null, body: "This release extends what plugins can do." },
|
||||
{
|
||||
title: "Plugins",
|
||||
body: "- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
},
|
||||
{ title: "Fixed", body: "- Fixed the mobile terminal keyboard closing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.2",
|
||||
date: "2026-09-02",
|
||||
sections: [{ title: "Fixed", body: "- Fixed a thing" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops everything above the first release heading", () => {
|
||||
const releases = parseChangelog(
|
||||
"# Changelog\n\nAll notable changes.\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "- One" }]);
|
||||
});
|
||||
|
||||
it("returns nothing for a document with no releases", () => {
|
||||
expect(parseChangelog("")).toEqual([]);
|
||||
expect(parseChangelog("# Changelog\n\nNothing yet.\n")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("release headings", () => {
|
||||
const headings: [string, { version: string; date: string }][] = [
|
||||
["## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3", { version: "1.2.3", date: "" }],
|
||||
["## [1.2.3] - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## v1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 — 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 – 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 (2026-01-01)", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - January 1, 2026", { version: "1.2.3", date: "January 1, 2026" }],
|
||||
[
|
||||
"## [1.2.3](https://example.com/v1.2.3) - 2026-01-01",
|
||||
{ version: "1.2.3", date: "2026-01-01" },
|
||||
],
|
||||
["## Unreleased", { version: "Unreleased", date: "" }],
|
||||
["## [Unreleased]", { version: "Unreleased", date: "" }],
|
||||
["## 1.2.3 - 2026-01-01 ", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - 2026-01-01 ##", { version: "1.2.3", date: "2026-01-01" }],
|
||||
[" ## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 0.8.0-beta.1 - 2026-09-08", { version: "0.8.0-beta.1", date: "2026-09-08" }],
|
||||
];
|
||||
|
||||
for (const [heading, expected] of headings) {
|
||||
it(`reads ${JSON.stringify(heading)}`, () => {
|
||||
const [release] = parseChangelog(`${heading}\n\n- Entry\n`);
|
||||
expect({ version: release.version, date: release.date }).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps both entries when a version appears twice", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-02\n\n- Two\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases.map(releaseDate)).toEqual(["2026-01-02", "2026-01-01"]);
|
||||
});
|
||||
|
||||
it("does not treat a deeper heading as a release", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n#### Deep\n\n- One\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "#### Deep\n\n- One" }]);
|
||||
});
|
||||
|
||||
it("does not treat an unspaced hash run as a heading", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n##notaheading\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections[0].body).toBe("##notaheading");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sections", () => {
|
||||
it("names sections from the document instead of a fixed list", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-01\n\n### Security\n\n- One\n\n### Deprecated\n\n- Two\n",
|
||||
);
|
||||
expect(releases[0].sections.map(sectionTitle)).toEqual(["Security", "Deprecated"]);
|
||||
});
|
||||
|
||||
it("keeps a section that has a heading but no body", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n### Added\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([
|
||||
{ title: "Fixed", body: "" },
|
||||
{ title: "Added", body: "- One" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops an empty lead section", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([{ title: "Fixed", body: "- One" }]);
|
||||
});
|
||||
|
||||
it("keeps a release that is only prose", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\nJust a note.\n");
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "Just a note." }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("block kinds it does not interpret", () => {
|
||||
it("passes a block quote, callout, table and embed through untouched", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Notes",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> This release drops macOS 12.",
|
||||
"",
|
||||
"| Platform | Status |",
|
||||
"| --- | --- |",
|
||||
"| macOS | Shipped |",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
'<video src="https://example.com/demo.mp4" controls></video>',
|
||||
"",
|
||||
'<iframe src="https://example.com/embed"></iframe>',
|
||||
"",
|
||||
"<details><summary>More</summary>Hidden</details>",
|
||||
].join("\n");
|
||||
|
||||
expect(bodies(source)).toEqual([source.split("\n").slice(4).join("\n")]);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fenced code block", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"```md",
|
||||
"## 9.9.9 - 2099-01-01",
|
||||
"### Fake section",
|
||||
"```",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
const releases = parseChangelog(source);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([
|
||||
{
|
||||
title: "Added",
|
||||
body: "```md\n## 9.9.9 - 2099-01-01\n### Fake section\n```\n\n- Real entry",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks tilde fences and longer fence runs", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"~~~~",
|
||||
"## 9.9.9",
|
||||
"```",
|
||||
"~~~~",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fence nested in a list item", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"- Entry with a sample",
|
||||
"",
|
||||
" ```",
|
||||
" ## 9.9.9",
|
||||
" ```",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("reads CRLF documents", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\r\n\r\n### Fixed\r\n\r\n- One\r\n");
|
||||
expect(releases).toEqual([
|
||||
{ version: "1.0.0", date: "2026-01-01", sections: [{ title: "Fixed", body: "- One" }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChangelogDate", () => {
|
||||
it("localizes an ISO date without shifting it across a timezone", () => {
|
||||
expect(formatChangelogDate("2026-09-08")).toBe("September 8, 2026");
|
||||
});
|
||||
|
||||
it("shows anything else as authored", () => {
|
||||
expect(formatChangelogDate("September 8, 2026")).toBe("September 8, 2026");
|
||||
expect(formatChangelogDate("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the repository's own CHANGELOG.md", () => {
|
||||
const markdown = readFileSync(
|
||||
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
const releases = parseChangelog(markdown);
|
||||
|
||||
it("parses every release heading", () => {
|
||||
expect(releases.length).toBeGreaterThan(50);
|
||||
for (const release of releases) {
|
||||
expect(release.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(release.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(release.sections.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every authored line inside a release", () => {
|
||||
const documentLines = markdown.split("\n");
|
||||
const fromFirstRelease = documentLines.slice(
|
||||
documentLines.findIndex((line) => line.startsWith("## ")),
|
||||
);
|
||||
|
||||
expect(releases.flatMap(authoredLines).filter(isNotBlank)).toEqual(
|
||||
fromFirstRelease.filter(isNotBlank).filter((line) => !line.startsWith("## ")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Structural parser for a CHANGELOG.md document.
|
||||
*
|
||||
* The document is authored in the repository, fetched at runtime, and rewritten
|
||||
* on every release, so this parser commits to exactly two facts: a `##` heading
|
||||
* starts a release and a `###` heading starts a section inside it. Everything
|
||||
* else — prose, callouts, block quotes, fenced code, images, video and embed
|
||||
* HTML, tables, nested lists — is carried through verbatim and handed to the
|
||||
* Markdown renderer. A new block kind in the changelog therefore needs no
|
||||
* change here.
|
||||
*
|
||||
* Section titles are read, never matched: renaming "Improved" or adding a
|
||||
* section is a content change, not a code change.
|
||||
*
|
||||
* The one way a line scan can be fooled is a fenced block whose contents look
|
||||
* like a heading, so fences are tracked.
|
||||
*/
|
||||
|
||||
export interface ChangelogSection {
|
||||
/** The `###` heading text, or null for content that precedes the first one. */
|
||||
title: string | null;
|
||||
/** Verbatim markdown under the heading. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ChangelogRelease {
|
||||
/** Version as authored, stripped of link, bracket and `v` decoration. */
|
||||
version: string;
|
||||
/** The heading tail after the version. Empty when the heading carried none. */
|
||||
date: string;
|
||||
/** Sections in document order; the untitled lead section comes first. */
|
||||
sections: ChangelogSection[];
|
||||
}
|
||||
|
||||
// `##` / `###` must be followed by whitespace, which is what keeps each pattern
|
||||
// from matching one level deeper.
|
||||
const RELEASE_HEADING = /^ {0,3}##(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const SECTION_HEADING = /^ {0,3}###(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const FENCE_OPEN = /^\s*(`{3,}|~{3,})/;
|
||||
const ATX_CLOSING = /[ \t]+#+[ \t]*$/;
|
||||
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
||||
const DASH_SEPARATED_DATE = /^(.*?)\s+[-–—]\s+(.+)$/;
|
||||
const PARENTHESIZED_DATE = /^(.*?)\s*\((.+)\)$/;
|
||||
const BRACKETED_VERSION = /^\[(.*)\]$/;
|
||||
const LEADING_V = /^v(?=\d)/i;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
interface SectionDraft {
|
||||
title: string | null;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface ReleaseDraft {
|
||||
version: string;
|
||||
date: string;
|
||||
sections: SectionDraft[];
|
||||
}
|
||||
|
||||
export function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
const releases: ReleaseDraft[] = [];
|
||||
let fence: string | null = null;
|
||||
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const fenceEdge = line.match(FENCE_OPEN)?.[1];
|
||||
if (fenceEdge) {
|
||||
if (!fence) {
|
||||
fence = fenceEdge;
|
||||
} else if (fenceEdge[0] === fence[0] && fenceEdge.length >= fence.length) {
|
||||
fence = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fence) {
|
||||
const releaseHeading = line.match(RELEASE_HEADING);
|
||||
if (releaseHeading) {
|
||||
releases.push({
|
||||
...splitReleaseHeading(releaseHeading[1] ?? ""),
|
||||
sections: [{ title: null, lines: [] }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionHeading = line.match(SECTION_HEADING);
|
||||
if (sectionHeading && releases.length > 0) {
|
||||
releases[releases.length - 1].sections.push({
|
||||
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim() || null,
|
||||
lines: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const release = releases.at(-1);
|
||||
if (release) release.sections[release.sections.length - 1].lines.push(line);
|
||||
}
|
||||
|
||||
return releases.map(materializeRelease);
|
||||
}
|
||||
|
||||
function materializeRelease(draft: ReleaseDraft): ChangelogRelease {
|
||||
const sections = draft.sections
|
||||
.map((section) => ({ title: section.title, body: trimBlankLines(section.lines) }))
|
||||
// A heading with an empty body still names something; an empty lead section
|
||||
// is just the gap between the release heading and the first section.
|
||||
.filter((section) => section.title !== null || section.body.length > 0);
|
||||
return { version: draft.version, date: draft.date, sections };
|
||||
}
|
||||
|
||||
function trimBlankLines(lines: string[]): string {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && lines[start].trim().length === 0) start += 1;
|
||||
while (end > start && lines[end - 1].trim().length === 0) end -= 1;
|
||||
return lines.slice(start, end).join("\n");
|
||||
}
|
||||
|
||||
function splitReleaseHeading(heading: string): { version: string; date: string } {
|
||||
const plain = heading.replace(ATX_CLOSING, "").replace(MARKDOWN_LINK, "$1").trim();
|
||||
|
||||
const dashed = plain.match(DASH_SEPARATED_DATE);
|
||||
if (dashed) return { version: normalizeVersion(dashed[1]), date: dashed[2].trim() };
|
||||
|
||||
const parenthesized = plain.match(PARENTHESIZED_DATE);
|
||||
if (parenthesized) {
|
||||
return { version: normalizeVersion(parenthesized[1]), date: parenthesized[2].trim() };
|
||||
}
|
||||
|
||||
return { version: normalizeVersion(plain), date: "" };
|
||||
}
|
||||
|
||||
function normalizeVersion(raw: string): string {
|
||||
const unbracketed = raw.trim().replace(BRACKETED_VERSION, "$1").trim();
|
||||
return unbracketed.replace(LEADING_V, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO dates become the reader's locale; anything else the author wrote is shown
|
||||
* as authored rather than guessed at.
|
||||
*/
|
||||
export function formatChangelogDate(date: string): string {
|
||||
if (!ISO_DATE.test(date)) return date;
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
const value = new Date(Date.UTC(year, month - 1, day));
|
||||
if (Number.isNaN(value.getTime())) return date;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* How much of the changelog is on screen.
|
||||
*
|
||||
* The latest release renders as soon as there is one, because that is what
|
||||
* nearly every open is asking for. The rest of the first page follows
|
||||
* immediately, and every Show more after it, as transitions: React then slices
|
||||
* that work around the opening animation instead of committing hundreds of list
|
||||
* items in one task. The whole document is well over a thousand of them.
|
||||
*
|
||||
* The input is "releases are on screen", not "the sheet is open". A cold open
|
||||
* spends its first moment on the loading state, and stepping the count up
|
||||
* during it would land the whole first page in the one commit that follows —
|
||||
* the exact commit this exists to split.
|
||||
*/
|
||||
const LATEST_RELEASE_ONLY = 1;
|
||||
const FIRST_PAGE = 5;
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export interface RevealedReleases {
|
||||
count: number;
|
||||
showMore: () => void;
|
||||
}
|
||||
|
||||
export function useRevealedReleases(hasReleases: boolean): RevealedReleases {
|
||||
const [count, setCount] = useState(LATEST_RELEASE_ONLY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasReleases) {
|
||||
setCount(LATEST_RELEASE_ONLY);
|
||||
return;
|
||||
}
|
||||
startTransition(() => setCount(FIRST_PAGE));
|
||||
}, [hasReleases]);
|
||||
|
||||
const showMore = useCallback(() => {
|
||||
startTransition(() => setCount((current) => current + PAGE_SIZE));
|
||||
}, []);
|
||||
|
||||
return { count, showMore };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useChangelogStore } from "./internal/changelog-store";
|
||||
|
||||
/** Opens the What's new sheet from anywhere, including outside React. */
|
||||
export function openChangelog(): void {
|
||||
useChangelogStore.getState().open();
|
||||
}
|
||||
@@ -171,6 +171,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
desktopScrollContainer: {
|
||||
// Grows only when the card has an explicit `desktopHeight`; a content-sized
|
||||
// card has nothing to grow into. Without it a fixed-height card with short
|
||||
// content leaves the footer stranded in the middle.
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
|
||||
@@ -24,11 +24,11 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openChangelog } from "@/changelog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
const ThemedActivity = withUnistyles(Activity);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
@@ -97,10 +97,6 @@ export function SidebarHelpMenu() {
|
||||
void openExternalUrl(GITHUB_ISSUE_URL);
|
||||
}, []);
|
||||
|
||||
const openChangelog = useCallback(() => {
|
||||
void openExternalUrl(CHANGELOG_URL);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
} from "@/desktop/updates/resolve-update-callout";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { openChangelog } from "@/changelog";
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function renderBody(body: UpdateCalloutBody, t: ReturnType<typeof useTranslation>["t"]): ReactNode {
|
||||
if (body.kind === "installing") return t("desktop.updates.callout.installingDescription");
|
||||
@@ -52,9 +51,6 @@ export function UpdateCalloutSource() {
|
||||
} = useDesktopAppUpdater();
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const openChangelog = useStableEvent(() => {
|
||||
void openExternalUrl(CHANGELOG_URL);
|
||||
});
|
||||
const install = useStableEvent(() => {
|
||||
void installUpdate();
|
||||
});
|
||||
@@ -111,7 +107,6 @@ export function UpdateCalloutSource() {
|
||||
install,
|
||||
isDesktopApp,
|
||||
isInstalling,
|
||||
openChangelog,
|
||||
retry,
|
||||
status,
|
||||
theme.colors.foregroundMuted,
|
||||
|
||||
@@ -1042,6 +1042,16 @@ export const ar: TranslationResources = {
|
||||
updateHost: "حدّث هذا المضيف لإدارة التسميات.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "ما الجديد",
|
||||
installed: "مثبّت",
|
||||
showMore: "عرض المزيد",
|
||||
openWebsite: "سجل التغييرات الكامل",
|
||||
error: {
|
||||
title: "تعذّر تحميل سجل التغييرات",
|
||||
description: "تحقق من اتصالك وحاول مرة أخرى.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "تفضيلات العرض",
|
||||
@@ -2067,6 +2077,7 @@ export const ar: TranslationResources = {
|
||||
about: {
|
||||
title: "عن",
|
||||
appVersion: "نسخة التطبيق",
|
||||
whatsNewHint: "ملاحظات الإصدار لكل نسخة",
|
||||
thisDevice: "هذا الجهاز",
|
||||
connectedHosts: "المضيفين المتصلين",
|
||||
offline: "غير متصل",
|
||||
|
||||
@@ -1051,6 +1051,16 @@ export const en = {
|
||||
updateHost: "Update this host to manage labels.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "What's new",
|
||||
installed: "Installed",
|
||||
showMore: "Show more",
|
||||
openWebsite: "Full changelog",
|
||||
error: {
|
||||
title: "Unable to load the changelog",
|
||||
description: "Check your connection and try again.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Display preferences",
|
||||
@@ -2172,6 +2182,7 @@ export const en = {
|
||||
about: {
|
||||
title: "About",
|
||||
appVersion: "App version",
|
||||
whatsNewHint: "Release notes for every version",
|
||||
thisDevice: "This device",
|
||||
connectedHosts: "Connected hosts",
|
||||
offline: "Offline",
|
||||
|
||||
@@ -1076,6 +1076,16 @@ export const es: TranslationResources = {
|
||||
updateHost: "Actualiza este host para gestionar etiquetas.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Novedades",
|
||||
installed: "Instalada",
|
||||
showMore: "Mostrar más",
|
||||
openWebsite: "Registro de cambios completo",
|
||||
error: {
|
||||
title: "No se pudo cargar el registro de cambios",
|
||||
description: "Comprueba tu conexión e inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Preferencias de visualización",
|
||||
@@ -2117,6 +2127,7 @@ export const es: TranslationResources = {
|
||||
about: {
|
||||
title: "Acerca de",
|
||||
appVersion: "Versión de la aplicación",
|
||||
whatsNewHint: "Notas de versión de cada release",
|
||||
thisDevice: "este dispositivo",
|
||||
connectedHosts: "Anfitriones conectados",
|
||||
offline: "Desconectado",
|
||||
|
||||
@@ -1075,6 +1075,16 @@ export const fr: TranslationResources = {
|
||||
updateHost: "Mettez à jour cet hôte pour gérer les étiquettes.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Nouveautés",
|
||||
installed: "Installée",
|
||||
showMore: "Afficher plus",
|
||||
openWebsite: "Journal des modifications complet",
|
||||
error: {
|
||||
title: "Impossible de charger le journal des modifications",
|
||||
description: "Vérifiez votre connexion et réessayez.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Préférences d'affichage",
|
||||
@@ -2120,6 +2130,7 @@ export const fr: TranslationResources = {
|
||||
about: {
|
||||
title: "À propos",
|
||||
appVersion: "Version de l'application",
|
||||
whatsNewHint: "Notes de version pour chaque release",
|
||||
thisDevice: "Cet appareil",
|
||||
connectedHosts: "Hôtes connectés",
|
||||
offline: "Hors ligne",
|
||||
|
||||
@@ -1053,6 +1053,16 @@ export const ja: TranslationResources = {
|
||||
updateHost: "ラベルを管理するにはホストを更新してください。",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "新着情報",
|
||||
installed: "インストール済み",
|
||||
showMore: "もっと見る",
|
||||
openWebsite: "変更履歴をすべて表示",
|
||||
error: {
|
||||
title: "変更履歴を読み込めません",
|
||||
description: "接続を確認してもう一度お試しください。",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "表示設定",
|
||||
@@ -2084,6 +2094,7 @@ export const ja: TranslationResources = {
|
||||
about: {
|
||||
title: "アプリ情報",
|
||||
appVersion: "アプリバージョン",
|
||||
whatsNewHint: "各バージョンのリリースノート",
|
||||
thisDevice: "このデバイス",
|
||||
connectedHosts: "接続されているホスト",
|
||||
offline: "オフライン",
|
||||
|
||||
@@ -1049,6 +1049,16 @@ export const ko: TranslationResources = {
|
||||
updateHost: "레이블을 관리하려면 호스트를 업데이트하세요.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "새로운 소식",
|
||||
installed: "설치됨",
|
||||
showMore: "더 보기",
|
||||
openWebsite: "전체 변경 내역",
|
||||
error: {
|
||||
title: "변경 내역을 불러오지 못했습니다",
|
||||
description: "연결을 확인한 후 다시 시도하세요.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "표시 설정",
|
||||
@@ -2078,6 +2088,7 @@ export const ko: TranslationResources = {
|
||||
about: {
|
||||
title: "정보",
|
||||
appVersion: "앱 버전",
|
||||
whatsNewHint: "모든 버전의 릴리스 노트",
|
||||
thisDevice: "이 기기",
|
||||
connectedHosts: "연결된 호스트",
|
||||
offline: "오프라인",
|
||||
|
||||
@@ -1067,6 +1067,16 @@ export const ptBR: TranslationResources = {
|
||||
updateHost: "Atualize este host para gerenciar etiquetas.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Novidades",
|
||||
installed: "Instalada",
|
||||
showMore: "Mostrar mais",
|
||||
openWebsite: "Changelog completo",
|
||||
error: {
|
||||
title: "Não foi possível carregar o changelog",
|
||||
description: "Verifique sua conexão e tente novamente.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Preferências de exibição",
|
||||
@@ -2101,6 +2111,7 @@ export const ptBR: TranslationResources = {
|
||||
about: {
|
||||
title: "Sobre",
|
||||
appVersion: "Versão do app",
|
||||
whatsNewHint: "Notas de versão de cada release",
|
||||
thisDevice: "Este dispositivo",
|
||||
connectedHosts: "Hosts conectados",
|
||||
offline: "Offline",
|
||||
|
||||
@@ -1057,6 +1057,16 @@ export const ru: TranslationResources = {
|
||||
updateHost: "Обновите этот хост для управления метками.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Что нового",
|
||||
installed: "Установлена",
|
||||
showMore: "Показать ещё",
|
||||
openWebsite: "Полный список изменений",
|
||||
error: {
|
||||
title: "Не удалось загрузить список изменений",
|
||||
description: "Проверьте подключение и попробуйте снова.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Настройки отображения",
|
||||
@@ -2102,6 +2112,7 @@ export const ru: TranslationResources = {
|
||||
about: {
|
||||
title: "О приложении",
|
||||
appVersion: "Версия приложения",
|
||||
whatsNewHint: "Заметки о выпуске для каждой версии",
|
||||
thisDevice: "Это устройство",
|
||||
connectedHosts: "Подключенные хосты",
|
||||
offline: "Оффлайн",
|
||||
|
||||
@@ -1034,6 +1034,16 @@ export const zhCN: TranslationResources = {
|
||||
updateHost: "请更新此主机以管理标签。",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "新功能",
|
||||
installed: "已安装",
|
||||
showMore: "显示更多",
|
||||
openWebsite: "完整更新日志",
|
||||
error: {
|
||||
title: "无法加载更新日志",
|
||||
description: "请检查网络连接后重试。",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "显示偏好",
|
||||
@@ -2043,6 +2053,7 @@ export const zhCN: TranslationResources = {
|
||||
about: {
|
||||
title: "关于",
|
||||
appVersion: "应用版本",
|
||||
whatsNewHint: "每个版本的发布说明",
|
||||
thisDevice: "此设备",
|
||||
connectedHosts: "已连接的 Host",
|
||||
offline: "离线",
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Sparkles,
|
||||
Blocks,
|
||||
PanelsTopLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
@@ -91,6 +92,7 @@ import { isElectronRuntime } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openChangelog } from "@/changelog";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
@@ -607,6 +609,7 @@ function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSection
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
||||
</View>
|
||||
<WhatsNewRow />
|
||||
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
@@ -618,6 +621,33 @@ function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSection
|
||||
);
|
||||
}
|
||||
|
||||
function WhatsNewRow() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[settingsStyles.row, settingsStyles.rowBorder]}
|
||||
onPress={openChangelog}
|
||||
accessibilityRole="button"
|
||||
testID="settings-whats-new"
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("changelog.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.about.whatsNewHint")}</Text>
|
||||
</View>
|
||||
<ChevronRight
|
||||
size={theme.iconSize.sm}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeVersion(version: string | null | undefined): string | null {
|
||||
const trimmed = version?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
Reference in New Issue
Block a user