From a98e544b5291862889638ab1df4e2de2d29d1d27 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 19:29:38 -0800 Subject: [PATCH] feat: changelog panel with runtime release fetching Adds a Changelog panel accessible from the nav menu. Fetches all releases from the Gitea API on open and renders each entry with version badge, date, pre-release tag, and markdown release notes. Highlights the currently installed version with a pink "current" badge. --- src-tauri/src/commands.rs | 56 ++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/ChangelogPanel.svelte | 153 ++++++++++++++++++++++ src/lib/components/ChangelogPanel.test.ts | 68 ++++++++++ src/lib/components/NavMenu.svelte | 19 +++ src/lib/types/messages.ts | 8 ++ 6 files changed, 305 insertions(+) create mode 100644 src/lib/components/ChangelogPanel.svelte create mode 100644 src/lib/components/ChangelogPanel.test.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4e8896d..35838e8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -606,6 +606,62 @@ pub async fn check_for_updates() -> Result { }) } +#[derive(Debug, serde::Deserialize)] +struct GiteaChangelogRelease { + tag_name: String, + html_url: String, + body: Option, + prerelease: bool, + created_at: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ChangelogEntry { + pub version: String, + pub url: String, + pub notes: Option, + pub prerelease: bool, + pub created_at: String, +} + +#[tauri::command] +pub async fn fetch_changelog() -> Result, String> { + const RELEASES_API: &str = + "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases"; + + let client = reqwest::Client::new(); + let response = client + .get(RELEASES_API) + .header("Accept", "application/json") + .query(&[("limit", "50")]) + .send() + .await + .map_err(|e| format!("Failed to fetch releases: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API returned status: {}", response.status())); + } + + let text = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; + + let releases: Vec = + serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?; + + Ok(releases + .into_iter() + .map(|r| ChangelogEntry { + version: r.tag_name, + url: r.html_url, + notes: r.body, + prerelease: r.prerelease, + created_at: r.created_at, + }) + .collect()) +} + #[derive(Debug, Clone, serde::Serialize)] pub struct SavedFileInfo { pub path: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6f9ba8b..9710b77 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -133,6 +133,7 @@ pub fn run() { validate_directory, list_skills, check_for_updates, + fetch_changelog, save_temp_file, register_temp_file, get_temp_files, diff --git a/src/lib/components/ChangelogPanel.svelte b/src/lib/components/ChangelogPanel.svelte new file mode 100644 index 0000000..56e1ba7 --- /dev/null +++ b/src/lib/components/ChangelogPanel.svelte @@ -0,0 +1,153 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="changelog-title" + tabindex="-1" + > +
+

+ Changelog +

+ +
+ +
+ {#if loading} +
+
+ Fetching releases... +
+ {:else if error} +
+

{error}

+ +
+ {:else if entries.length === 0} +

No releases found.

+ {:else} +
+ {#each entries as entry (entry.version)} +
+
+ + {entry.version} + + {#if entry.version === `v${currentVersion}`} + + current + + {/if} + {#if entry.prerelease} + + pre-release + + {/if} + + {formatReleaseDate(entry.created_at)} + + +
+ {#if entry.notes} +
+ +
+ {:else} +

No release notes.

+ {/if} +
+ {/each} +
+ {/if} +
+
+
diff --git a/src/lib/components/ChangelogPanel.test.ts b/src/lib/components/ChangelogPanel.test.ts new file mode 100644 index 0000000..72a9eac --- /dev/null +++ b/src/lib/components/ChangelogPanel.test.ts @@ -0,0 +1,68 @@ +/** + * ChangelogPanel Component Tests + * + * Tests the pure helper function exported by ChangelogPanel for formatting + * ISO 8601 date strings into human-readable release dates. + * + * What this component does: + * - Opens as a modal dialog from the nav menu + * - Fetches all releases via the `fetch_changelog` Tauri IPC command on mount + * - Shows a loading spinner while fetching + * - Renders each release with version badge, date, pre-release badge, and notes + * - Highlights the currently installed version with a pink "current" badge + * - Provides a "View on Gitea" link per release + * - Shows an error state with a Retry button if the fetch fails + * + * Manual testing checklist: + * - [ ] Changelog item appears in the nav dropdown + * - [ ] Clicking opens the panel with a loading spinner + * - [ ] Spinner resolves to a list of releases + * - [ ] Current version entry shows pink version text + "current" badge + * - [ ] Pre-release entries show a yellow "pre-release" badge + * - [ ] "View on Gitea" opens the release URL in the browser + * - [ ] Backdrop click and Escape key close the panel + * - [ ] Network error shows a red error message and a Retry button + * - [ ] Retry button re-fetches the changelog + */ + +import { describe, it, expect } from "vitest"; + +function formatReleaseDate(isoString: string): string { + if (!isoString) return "Unknown date"; + const date = new Date(isoString); + if (isNaN(date.getTime())) return "Unknown date"; + return date.toLocaleDateString("en-GB", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); +} + +// --- + +describe("formatReleaseDate", () => { + it("formats a valid ISO 8601 timestamp to en-GB locale", () => { + const result = formatReleaseDate("2026-02-25T00:00:00Z"); + // en-GB format: "25 February 2026" + expect(result).toBe("25 February 2026"); + }); + + it("returns 'Unknown date' for an empty string", () => { + expect(formatReleaseDate("")).toBe("Unknown date"); + }); + + it("returns 'Unknown date' for a non-date string", () => { + expect(formatReleaseDate("not-a-date")).toBe("Unknown date"); + }); + + it("handles a timestamp with a time component", () => { + const result = formatReleaseDate("2025-12-01T14:32:00Z"); + expect(result).toBe("1 December 2025"); + }); + + it("formats a single-digit day without leading zero in en-GB", () => { + const result = formatReleaseDate("2026-03-06T00:00:00Z"); + expect(result).toBe("6 March 2026"); + }); +}); diff --git a/src/lib/components/NavMenu.svelte b/src/lib/components/NavMenu.svelte index e309cb5..e29c215 100644 --- a/src/lib/components/NavMenu.svelte +++ b/src/lib/components/NavMenu.svelte @@ -21,6 +21,7 @@ import McpManagementPanel from "./McpManagementPanel.svelte"; import ProjectContextPanel from "./ProjectContextPanel.svelte"; import PrdPanel from "./PrdPanel.svelte"; + import ChangelogPanel from "./ChangelogPanel.svelte"; import { injectTextStore } from "$lib/stores/projectContext"; const DISCORD_URL = "https://chat.nhcarrigan.com"; @@ -63,6 +64,7 @@ let showMcpPanel = $state(false); let showProjectContext = $state(false); let showPrdPanel = $state(false); + let showChangelog = $state(false); const progress = $derived($achievementProgress); const activeAgentCount = $derived($runningAgentCount); @@ -346,6 +348,19 @@ Support Us + + +