diff --git a/src/components/about-dialog.test.tsx b/src/components/about-dialog.test.tsx index 91ea0cf2..d1dcf633 100644 --- a/src/components/about-dialog.test.tsx +++ b/src/components/about-dialog.test.tsx @@ -8,10 +8,20 @@ const openerState = vi.hoisted(() => ({ openUrlMock: vi.fn(() => Promise.resolve()), })) +const changelogState = vi.hoisted(() => ({ + releases: [] as import("@/hooks/use-changelog").Release[], + loading: false, + error: null as string | null, +})) + vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: openerState.openUrlMock, })) +vi.mock("@/hooks/use-changelog", () => ({ + useChangelog: () => changelogState, +})) + describe("AboutDialog", () => { it("renders version, links, and maintainers", () => { render( {}} />) @@ -40,6 +50,20 @@ describe("AboutDialog", () => { expect(onClose).toHaveBeenCalled() }) + it("goes back to about view on Escape when showing changelog", async () => { + const onClose = vi.fn() + render() + + // Switch to changelog view. + await userEvent.click(screen.getByRole("button", { name: "View Changelog" })) + + // Press Escape; should go back to About view, not close. + await userEvent.keyboard("{Escape}") + + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByText("OpenUsage")).toBeInTheDocument() + }) + it("does not close on other keys", async () => { const onClose = vi.fn() render() diff --git a/src/components/about-dialog.tsx b/src/components/about-dialog.tsx index 9fc36104..31315c82 100644 --- a/src/components/about-dialog.tsx +++ b/src/components/about-dialog.tsx @@ -1,5 +1,7 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { ChangelogDialog } from "./changelog-dialog"; +import { Button } from "@/components/ui/button"; interface AboutDialogProps { version: string; @@ -29,8 +31,14 @@ function ExternalLink({ } export function AboutDialog({ version, onClose }: AboutDialogProps) { + const [view, setView] = useState<"about" | "changelog">("about"); + // Close on ESC key useEffect(() => { + if (view !== "about") { + return; + } + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); @@ -39,7 +47,7 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) { }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); + }, [onClose, view]); // Close when panel hides (loses visibility) useEffect(() => { @@ -59,6 +67,18 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) { } }; + if (view === "changelog") { + return ( + setView("about")} + // In changelog view, Escape should go back to About instead of + // closing the entire dialog, so hand off to setView. + onClose={() => setView("about")} + /> + ); + } + return (
OpenUsage - - v{version} - +
+ + v{version} + + +

@@ -103,3 +133,4 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) {

); } + diff --git a/src/components/changelog-dialog.test.tsx b/src/components/changelog-dialog.test.tsx new file mode 100644 index 00000000..0fd35eeb --- /dev/null +++ b/src/components/changelog-dialog.test.tsx @@ -0,0 +1,284 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi, beforeEach } from "vitest" + +const openerState = vi.hoisted(() => ({ + openUrlMock: vi.fn(() => Promise.resolve()), +})) + +const changelogState = vi.hoisted(() => ({ + releases: [] as import("@/hooks/use-changelog").Release[], + loading: false, + error: null as string | null, +})) + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: openerState.openUrlMock, +})) + +vi.mock("@/hooks/use-changelog", () => ({ + useChangelog: () => changelogState, +})) + +import { ChangelogDialog } from "@/components/changelog-dialog" + +describe("ChangelogDialog", () => { + beforeEach(() => { + changelogState.releases = [] + changelogState.loading = false + changelogState.error = null + openerState.openUrlMock.mockClear() + }) + + it("renders loading state", () => { + changelogState.loading = true + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("Fetching release info...")).toBeInTheDocument() + }) + + it("renders error state and shows retry button", async () => { + changelogState.error = "something went wrong" + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("Failed to load release notes")).toBeInTheDocument() + expect(screen.getByText("something went wrong")).toBeInTheDocument() + + const retryButton = screen.getByRole("button", { name: "Try again" }) + expect(retryButton).toBeInTheDocument() + }) + + it("renders current release with markdown content and GitHub link", async () => { + const body = + "Intro\n\n" + + "## Heading\n" + + "- item\n" + + "PR #123 by @user in commit abcdef1\n" + + "See [docs](https://example.com/docs) and https://example.com/plain" + + changelogState.releases = [ + { + id: 1, + tag_name: "v1.2.3", + name: "v1.2.3", + body, + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.2.3", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("v1.2.3")).toBeInTheDocument() + expect(screen.getByText("Intro")).toBeInTheDocument() + expect(screen.getByText("Heading")).toBeInTheDocument() + expect(screen.getByText("item")).toBeInTheDocument() + + // GitHub button opens the release URL. + await userEvent.click(screen.getByRole("button", { name: "GitHub" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/releases/tag/v1.2.3", + ) + + openerState.openUrlMock.mockClear() + + // Markdown link button. + await userEvent.click(screen.getByRole("button", { name: "docs" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith("https://example.com/docs") + + openerState.openUrlMock.mockClear() + + // PR, user, and commit buttons. + await userEvent.click(screen.getByRole("button", { name: "#123" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/pull/123", + ) + + openerState.openUrlMock.mockClear() + + await userEvent.click(screen.getByRole("button", { name: "@user" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith("https://github.com/user") + + openerState.openUrlMock.mockClear() + + await userEvent.click(screen.getByRole("button", { name: "abcdef1" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/commit/abcdef1", + ) + + openerState.openUrlMock.mockClear() + + await userEvent.click(screen.getByRole("button", { name: "https://example.com/plain" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith("https://example.com/plain") + }) + + it("handles null body without crashing", () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.0", + name: "v1.0.0", + body: null as any, + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.0", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + // If it renders the title, we know it didn't crash when rendering the markdown. + expect(screen.getByText("v1.0.0")).toBeInTheDocument() + }) + + it("handles null published_at gracefully", () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.1", + name: "v1.0.1", + body: "body", + published_at: null, + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.1", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("v1.0.1")).toBeInTheDocument() + expect(screen.getByText("Unpublished release")).toBeInTheDocument() + }) + + it("shows link to full changelog when multiple releases exist", async () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.0", + name: "v1.0.0", + body: "body", + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.0", + }, + { + id: 2, + tag_name: "v0.9.0", + name: "v0.9.0", + body: "older", + published_at: "2024-01-01T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v0.9.0", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + const fullChangelogButton = screen.getByRole("button", { name: "full changelog" }) + await userEvent.click(fullChangelogButton) + + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/releases", + ) + }) + + it("renders fallback when no current release is found", async () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v0.1.0", + name: "v0.1.0", + body: "old", + published_at: "2023-01-01T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v0.1.0", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + expect( + screen.getByText("No specific notes for v9.9.9"), + ).toBeInTheDocument() + + await userEvent.click( + screen.getByRole("button", { name: "View all releases on GitHub" }), + ) + + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/releases", + ) + }) + + it("invokes navigation callbacks and closes on Escape", async () => { + const onBack = vi.fn() + const onClose = vi.fn() + + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.0", + name: "v1.0.0", + body: "body", + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.0", + }, + ] + + render( + , + ) + + // Back goes to previous view + await userEvent.click(screen.getByRole("button", { name: "Back" })) + expect(onBack).toHaveBeenCalled() + + // Escape should trigger onClose once + await userEvent.keyboard("{Escape}") + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + diff --git a/src/components/changelog-dialog.tsx b/src/components/changelog-dialog.tsx new file mode 100644 index 00000000..0cc2a807 --- /dev/null +++ b/src/components/changelog-dialog.tsx @@ -0,0 +1,283 @@ +import { useEffect } from "react" +import { Loader2, ChevronRight, ExternalLink as ExternalLinkIcon } from "lucide-react" +import { useChangelog } from "@/hooks/use-changelog" +import { Button } from "@/components/ui/button" +import { openUrl } from "@tauri-apps/plugin-opener" + +interface ChangelogDialogProps { + currentVersion: string + onBack: () => void + onClose: () => void +} + +function SimpleMarkdown({ content }: { content: string }) { + // Regex for identifying various markdown elements + const patterns = [ + // Markdown links: [label](url) + { type: "link", regex: /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g }, + // Plain URLs: https://... + { type: "url", regex: /(https?:\/\/[^\s<>]*[^\s<>.,:;!'")\]])/g }, + // Bolds: **text** or __text__ + { type: "bold", regex: /(\*\*|__)(.*?)\1/g }, + // Italics: *text* or _text_ + { type: "italic", regex: /(\*|_)(.*?)\1/g }, + // PR/Issue numbers: #123 + { type: "pr", regex: /(#\d+)/g }, + // Usernames: @user + { type: "user", regex: /(@[\w-]+)/g }, + // Commit hashes: 7 chars hex + { type: "commit", regex: /\b([a-f0-9]{7})\b/g }, + ]; + + const renderText = (text: string): React.ReactNode => { + let parts: Array<{ type: string; content: string; url?: string }> = [ + { type: "text", content: text }, + ]; + + patterns.forEach((pattern) => { + const newParts: typeof parts = []; + parts.forEach((part) => { + if (part.type !== "text") { + newParts.push(part); + return; + } + + let lastIndex = 0; + let match; + const regex = new RegExp(pattern.regex); + + while ((match = regex.exec(part.content)) !== null) { + if (match.index > lastIndex) { + newParts.push({ type: "text", content: part.content.slice(lastIndex, match.index) }); + } + + if (pattern.type === "link") { + newParts.push({ type: "link", content: match[1], url: match[2] }); + } else if (pattern.type === "bold") { + newParts.push({ type: "bold", content: match[2] }); + } else if (pattern.type === "italic") { + newParts.push({ type: "italic", content: match[2] }); + } else if (pattern.type === "pr") { + newParts.push({ type: "pr", content: match[1] }); + } else if (pattern.type === "user") { + newParts.push({ type: "user", content: match[1] }); + } else if (pattern.type === "commit") { + const isHex = /^[a-f0-9]+$/.test(match[1]); + if (isHex && match[1].length === 7) { + newParts.push({ type: "commit", content: match[1] }); + } else { + newParts.push({ type: "text", content: match[1] }); + } + } else if (pattern.type === "url") { + newParts.push({ type: "link", content: match[1], url: match[1] }); + } + + lastIndex = regex.lastIndex; + } + + if (lastIndex < part.content.length) { + newParts.push({ type: "text", content: part.content.slice(lastIndex) }); + } + }); + parts = newParts; + }); + + const linkClass = "text-[#58a6ff] hover:underline hover:text-[#58a6ff]/80 transition-colors cursor-pointer"; + + return parts.map((part, i) => { + if (part.type === "link") { + return ( + + ); + } + if (part.type === "bold") { + return {renderText(part.content)}; + } + if (part.type === "italic") { + return {renderText(part.content)}; + } + if (part.type === "pr") { + return ( + + ); + } + if (part.type === "user") { + return ( + + ); + } + if (part.type === "commit") { + return ( + + ); + } + return {part.content}; + }); + }; + + const lines = content.split("\n"); + return ( +
+ {lines.map((line, i) => { + const trimmed = line.trim(); + if (trimmed === "---" || trimmed === "***" || trimmed === "--") { + return
+ } + if (trimmed.startsWith("###")) { + return

{renderText(trimmed.replace(/^###\s*/, ""))}

+ } + if (trimmed.startsWith("##")) { + return

{renderText(trimmed.replace(/^##\s*/, ""))}

+ } + if (trimmed.startsWith("-") || trimmed.startsWith("*")) { + if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + return ( +
+ + {renderText(trimmed.replace(/^[-*]\s*/, ""))} +
+ ) + } + } + if (!trimmed) return
+ return

{renderText(line)}

+ })} +
+ ) +} + +export function ChangelogDialog({ currentVersion, onBack, onClose }: ChangelogDialogProps) { + const { releases, loading, error } = useChangelog(currentVersion) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + onClose() + } + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + const currentRelease = releases.find(r => + r.tag_name === currentVersion || + r.tag_name === `v${currentVersion}` || + r.name === currentVersion || + r.name === `v${currentVersion}` + ) + + return ( +
+
+
+
+ +

Release Notes

+
+
+ +
+ {loading ? ( +
+ + Fetching release info... +
+ ) : error ? ( +
+ Failed to load release notes + {error} + +
+ ) : currentRelease ? ( +
+
+
+

{currentRelease.name || currentRelease.tag_name}

+

+ {currentRelease.published_at + ? (() => { + const d = new Date(currentRelease.published_at) + const year = d.getUTCFullYear() + const month = String(d.getUTCMonth() + 1).padStart(2, "0") + const day = String(d.getUTCDate()).padStart(2, "0") + return `Released on ${year}/${month}/${day}` + })() + : "Unpublished release"} +

+
+ +
+ +
+ +
+ + {releases.length >= 1 && ( +
+

+ Looking for older versions? Check the{" "} + +

+
+ )} +
+ ) : ( +
+ No specific notes for v{currentVersion} + This version might be a pre-release or local build. + +
+ )} +
+
+
+ ) +} diff --git a/src/hooks/use-changelog.test.tsx b/src/hooks/use-changelog.test.tsx new file mode 100644 index 00000000..8c40496d --- /dev/null +++ b/src/hooks/use-changelog.test.tsx @@ -0,0 +1,141 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { useChangelog, type Release } from "./use-changelog" + +describe("useChangelog", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + globalThis.fetch = vi.fn() as any + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it("fetches release by exact currentVersion tag", async () => { + const release: Release = { + id: 1, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "notes", + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.2.3", + } + + const response = { + ok: true, + status: 200, + json: async () => release, + } as any + + const fetchMock = vi.fn().mockResolvedValue(response) + globalThis.fetch = fetchMock as any + + const { result } = renderHook(() => useChangelog("v1.2.3")) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.releases).toHaveLength(1) + expect(result.current.releases[0]).toEqual(release) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/repos/robinebers/openusage/releases/tags/v1.2.3", + ) + }) + + it("falls back between v-prefixed and non-prefixed tags", async () => { + const notFoundResponse = { + ok: false, + status: 404, + json: async () => ({}), + } as any + + const release: Release = { + id: 2, + tag_name: "v1.0.0", + name: "v1.0.0", + body: "older", + published_at: "2023-01-01T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.0", + } + + const okResponse = { + ok: true, + status: 200, + json: async () => release, + } as any + + const fetchMock = vi + .fn() + // first try without v + .mockResolvedValueOnce(notFoundResponse) + // then try with v prefix + .mockResolvedValueOnce(okResponse) + + globalThis.fetch = fetchMock as any + + const { result } = renderHook(() => useChangelog("1.0.0")) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.releases).toHaveLength(1) + expect(result.current.releases[0]).toEqual(release) + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.github.com/repos/robinebers/openusage/releases/tags/v1.0.0", + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.github.com/repos/robinebers/openusage/releases/tags/1.0.0", + ) + }) + + it("returns empty releases when tag does not exist for any variant", async () => { + const notFoundResponse = { + ok: false, + status: 404, + json: async () => ({}), + } as any + + const fetchMock = vi.fn().mockResolvedValue(notFoundResponse) + globalThis.fetch = fetchMock as any + + const { result } = renderHook(() => useChangelog("9.9.9")) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + expect(result.current.releases).toHaveLength(0) + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it("sets error when fetch fails with non-404", async () => { + const badResponse = { + ok: false, + status: 500, + json: async () => ({}), + } as any + + const fetchMock = vi.fn().mockResolvedValue(badResponse) + globalThis.fetch = fetchMock as any + + const { result } = renderHook(() => useChangelog("1.0.0")) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.releases).toHaveLength(0) + expect(result.current.error).toBe("Failed to fetch releases") + }) + }) +}) + diff --git a/src/hooks/use-changelog.ts b/src/hooks/use-changelog.ts new file mode 100644 index 00000000..8caed052 --- /dev/null +++ b/src/hooks/use-changelog.ts @@ -0,0 +1,80 @@ +import { useState, useEffect } from "react" + +export interface Release { + id: number + tag_name: string + name: string | null + body: string | null + published_at: string | null + html_url: string +} + +async function fetchReleaseByTag(tag: string): Promise { + const url = `https://api.github.com/repos/robinebers/openusage/releases/tags/${encodeURIComponent( + tag, + )}` + const res = await fetch(url) + + if (res.status === 404) { + return null + } + + if (!res.ok) { + throw new Error("Failed to fetch releases") + } + + const data = (await res.json()) as Release + return data +} + +export function useChangelog(currentVersion: string) { + const [releases, setReleases] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let mounted = true + + const fetchForCurrentVersion = async () => { + setLoading(true) + setReleases([]) + setError(null) + try { + let release: Release | null = null + + if (currentVersion.startsWith("v")) { + release = + (await fetchReleaseByTag(currentVersion)) ?? + (await fetchReleaseByTag(currentVersion.slice(1))) + } else { + release = + (await fetchReleaseByTag(`v${currentVersion}`) ?? + (await fetchReleaseByTag(currentVersion))) + } + + if (mounted) { + setReleases(release ? [release] : []) + setError(null) + } + } catch (err) { + if (mounted) { + const message = + err instanceof Error ? err.message : "Failed to fetch releases" + setError(message) + } + } finally { + if (mounted) { + setLoading(false) + } + } + } + + fetchForCurrentVersion() + + return () => { + mounted = false + } + }, [currentVersion]) + + return { releases, loading, error } +}