generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more #197
@@ -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)]
|
||||
pub struct SavedFileInfo {
|
||||
pub path: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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 @@
|
||||
<span>Support Us</span>
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -475,6 +490,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showChangelog}
|
||||
<ChangelogPanel onClose={() => (showChangelog = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nav-item {
|
||||
display: flex;
|
||||
|
||||
@@ -188,3 +188,11 @@ export interface UpdateInfo {
|
||||
release_url: 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