generated from nhcarrigan/template
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.
This commit is contained in:
@@ -606,6 +606,62 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct GiteaChangelogRelease {
|
||||||
|
tag_name: String,
|
||||||
|
html_url: String,
|
||||||
|
body: Option<String>,
|
||||||
|
prerelease: bool,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ChangelogEntry {
|
||||||
|
pub version: String,
|
||||||
|
pub url: String,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub prerelease: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, 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<GiteaChangelogRelease> =
|
||||||
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct SavedFileInfo {
|
pub struct SavedFileInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub fn run() {
|
|||||||
validate_directory,
|
validate_directory,
|
||||||
list_skills,
|
list_skills,
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
|
fetch_changelog,
|
||||||
save_temp_file,
|
save_temp_file,
|
||||||
register_temp_file,
|
register_temp_file,
|
||||||
get_temp_files,
|
get_temp_files,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { ChangelogEntry } from "$lib/types/messages";
|
||||||
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
|
let entries = $state<ChangelogEntry[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let currentVersion = $state("");
|
||||||
|
|
||||||
|
export 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChangelog(): Promise<void> {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
entries = await invoke<ChangelogEntry[]>("fetch_changelog");
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
currentVersion = await getVersion();
|
||||||
|
await loadChangelog();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={onClose}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="changelog-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||||
|
<h2 id="changelog-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
Changelog
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1 p-6">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 border-2 border-[var(--accent-primary)] border-t-transparent rounded-full animate-spin"
|
||||||
|
></div>
|
||||||
|
<span class="ml-3 text-[var(--text-secondary)]">Fetching releases...</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-red-400 mb-4">{error}</p>
|
||||||
|
<button onclick={loadChangelog} class="btn-trans-gradient px-4 py-2 rounded text-sm">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if entries.length === 0}
|
||||||
|
<p class="text-center text-[var(--text-secondary)] py-12">No releases found.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#each entries as entry (entry.version)}
|
||||||
|
<div class="border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 px-4 py-3 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-mono font-semibold text-sm {entry.version === `v${currentVersion}`
|
||||||
|
? 'text-[var(--trans-pink)]'
|
||||||
|
: 'text-[var(--text-primary)]'}"
|
||||||
|
>
|
||||||
|
{entry.version}
|
||||||
|
</span>
|
||||||
|
{#if entry.version === `v${currentVersion}`}
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--trans-pink)]/20 text-[var(--trans-pink)] border border-[var(--trans-pink)]/30"
|
||||||
|
>
|
||||||
|
current
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if entry.prerelease}
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"
|
||||||
|
>
|
||||||
|
pre-release
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="ml-auto text-xs text-[var(--text-muted)]">
|
||||||
|
{formatReleaseDate(entry.created_at)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(entry.url)}
|
||||||
|
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||||
|
>
|
||||||
|
View on Gitea
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if entry.notes}
|
||||||
|
<div class="p-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
<Markdown content={entry.notes} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="p-4 text-xs text-[var(--text-muted)] italic">No release notes.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||||
import ProjectContextPanel from "./ProjectContextPanel.svelte";
|
import ProjectContextPanel from "./ProjectContextPanel.svelte";
|
||||||
import PrdPanel from "./PrdPanel.svelte";
|
import PrdPanel from "./PrdPanel.svelte";
|
||||||
|
import ChangelogPanel from "./ChangelogPanel.svelte";
|
||||||
import { injectTextStore } from "$lib/stores/projectContext";
|
import { injectTextStore } from "$lib/stores/projectContext";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
let showMcpPanel = $state(false);
|
let showMcpPanel = $state(false);
|
||||||
let showProjectContext = $state(false);
|
let showProjectContext = $state(false);
|
||||||
let showPrdPanel = $state(false);
|
let showPrdPanel = $state(false);
|
||||||
|
let showChangelog = $state(false);
|
||||||
|
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
const activeAgentCount = $derived($runningAgentCount);
|
const activeAgentCount = $derived($runningAgentCount);
|
||||||
@@ -346,6 +348,19 @@
|
|||||||
<span>Support Us</span>
|
<span>Support Us</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Changelog -->
|
||||||
|
<button onclick={menuAction(() => (showChangelog = true))} class="nav-item">
|
||||||
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12h6M9 16h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Changelog</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- About -->
|
<!-- About -->
|
||||||
<button onclick={menuAction(() => (showAbout = true))} class="nav-item">
|
<button onclick={menuAction(() => (showAbout = true))} class="nav-item">
|
||||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -475,6 +490,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showChangelog}
|
||||||
|
<ChangelogPanel onClose={() => (showChangelog = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -188,3 +188,11 @@ export interface UpdateInfo {
|
|||||||
release_url: string;
|
release_url: string;
|
||||||
release_notes?: string;
|
release_notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
url: string;
|
||||||
|
notes?: string;
|
||||||
|
prerelease: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user