import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { Box, ScrollBox, Text, TextAttributes, type ScrollBoxRenderable } from "../../../ui"; import { useShortcut } from "../../../react/input"; import { usePaneInstance } from "../../../state/app/context"; import { DataTableStackView, PaneStatusBody, useExternalLinkFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent, type PaneFooterSegment, } from "../../../components"; import { MarkdownText } from "../../../components/markdown-text"; import { fetchChangelogReleases, type ChangelogRelease } from "../../../updater/github-releases"; import { colors } from "../../../theme/colors"; import type { PaneProps } from "../../../types/plugin"; import type { PluginModule } from "../plugin-module"; import { usePluginPaneState } from "../../runtime"; import { isPlainKey } from "../../../utils/keyboard"; import { DEFAULT_CHANGELOG_SORT, nextChangelogSortPreference, resolveSelectedReleaseIndex, sortChangelogReleases, type ChangelogColumnId, } from "./model"; const CHANGELOG_LIMIT = 40; /** GitHub can hang or be blocked outright; the pane must still reach a verdict. */ const CHANGELOG_TIMEOUT_MS = 5_000; const CHANGELOG_CACHE_TTL_MS = 10 * 60 * 1000; // ponytail: process-lifetime cache, move into the updater module if other // surfaces start reading releases too. let cachedReleases: { releases: ChangelogRelease[]; fetchedAt: number } | null = null; type ChangelogColumn = DataTableColumn & { id: ChangelogColumnId }; type LoadStatus = "loading" | "loaded" | "error"; function formatReleaseDate(value: string): string { const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return ""; return new Date(timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); } function buildColumns(releases: ChangelogRelease[]): ChangelogColumn[] { const dateWidth = 12; const versionWidth = Math.min( Math.max(7, ...releases.map((release) => release.version.length)), 14, ); return [ { id: "date", label: "Date", width: dateWidth, align: "left" }, { id: "version", label: "Version", width: versionWidth, align: "left" }, { id: "title", label: "Title", width: 18, align: "left", flexGrow: 1 }, ]; } function releaseDetailTitle(release: ChangelogRelease): string { return release.title === release.version ? release.version : `${release.version} ${release.title}`; } function ChangelogDetail({ release, width, scrollRef, }: { release: ChangelogRelease; width: number; scrollRef: RefObject; }) { const lineWidth = Math.max(width - 2, 12); return ( {/* The stack title already carries the version. */} {formatReleaseDate(release.publishedAt)} ); } function ChangelogPane({ focused, width, height }: PaneProps) { const paneInstance = usePaneInstance(); const requestedVersion = paneInstance?.params?.version ?? null; const [releases, setReleases] = useState(() => cachedReleases?.releases ?? []); // The mount effect starts the request immediately, so the first paint is a // spinner and never "No changelog entries found". const [status, setStatus] = useState(() => (cachedReleases ? "loaded" : "loading")); const [error, setError] = useState(null); // The selected row and the open release are pane state, so a reload or a // shared layout comes back to the same release. const [selectedReleaseId, setSelectedReleaseId] = usePluginPaneState("selectedReleaseId", null); const [sortPreference, setSortPreference] = useState(DEFAULT_CHANGELOG_SORT); const [openReleaseId, setOpenReleaseId] = usePluginPaneState("openReleaseId", null); const detailScrollRef = useRef(null); const abortRef = useRef(null); const requestedVersionOpenedRef = useRef(false); const loadReleases = useCallback(async (force = false) => { if (!force && cachedReleases && Date.now() - cachedReleases.fetchedAt < CHANGELOG_CACHE_TTL_MS) { setReleases(cachedReleases.releases); setError(null); setStatus("loaded"); return; } abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, CHANGELOG_TIMEOUT_MS); setStatus("loading"); setError(null); try { const nextReleases = await fetchChangelogReleases(CHANGELOG_LIMIT, controller.signal); cachedReleases = { releases: nextReleases, fetchedAt: Date.now() }; setReleases(nextReleases); setStatus("loaded"); } catch (loadError) { // A newer load or an unmount owns the state now; a timeout does not. if (!timedOut && abortRef.current !== controller) return; setError(timedOut ? "GitHub did not answer in time" : loadError instanceof Error ? loadError.message : "Failed to load changelog"); setStatus("error"); } finally { clearTimeout(timer); if (abortRef.current === controller) { abortRef.current = null; } } }, []); useEffect(() => { void loadReleases(); return () => { abortRef.current?.abort(); abortRef.current = null; }; }, [loadReleases]); const sortedReleases = useMemo( () => sortChangelogReleases(releases, sortPreference), [releases, sortPreference], ); const activeSelectedIdx = resolveSelectedReleaseIndex(sortedReleases, selectedReleaseId); const activeSelectedReleaseId = sortedReleases[activeSelectedIdx]?.id ?? null; useEffect(() => { if (activeSelectedReleaseId !== selectedReleaseId) { setSelectedReleaseId(activeSelectedReleaseId); } }, [activeSelectedReleaseId, selectedReleaseId]); const openRelease = useMemo( () => openReleaseId ? releases.find((release) => release.id === openReleaseId) ?? null : null, [openReleaseId, releases], ); useEffect(() => { if (openReleaseId && !openRelease) { setOpenReleaseId(null); } }, [openRelease, openReleaseId]); // Opened via "what's new after update": jump straight to that release's notes. useEffect(() => { if (!requestedVersion || requestedVersionOpenedRef.current) return; const match = releases.find((release) => release.version.replace(/^v/, "") === requestedVersion.replace(/^v/, "")); if (!match) return; requestedVersionOpenedRef.current = true; setSelectedReleaseId(match.id); setOpenReleaseId(match.id); }, [releases, requestedVersion]); useEffect(() => { if (!openReleaseId) return; const scrollBox = detailScrollRef.current; if (scrollBox) scrollBox.scrollTop = 0; }, [openReleaseId]); useShortcut((event) => { if (!focused || !isPlainKey(event, "r")) return; event.stopPropagation?.(); event.preventDefault?.(); void loadReleases(true); }); const columns = useMemo(() => buildColumns(releases), [releases]); const scrollDetailBy = useCallback((delta: number) => { const scrollBox = detailScrollRef.current; if (!scrollBox?.viewport) return; const maxScrollTop = Math.max( 0, scrollBox.scrollHeight - scrollBox.viewport.height, ); scrollBox.scrollTop = Math.max( 0, Math.min(maxScrollTop, scrollBox.scrollTop + delta), ); }, []); const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => { if (isPlainKey(event, "j", "down")) { event.stopPropagation?.(); event.preventDefault?.(); scrollDetailBy(1); return true; } if (isPlainKey(event, "k", "up")) { event.stopPropagation?.(); event.preventDefault?.(); scrollDetailBy(-1); return true; } return false; }, [scrollDetailBy]); const renderCell = useCallback(( release: ChangelogRelease, column: ChangelogColumn, _index: number, rowState: { selected: boolean }, ): DataTableCell => { const selectedColor = rowState.selected ? colors.selectedText : undefined; switch (column.id) { case "date": return { text: formatReleaseDate(release.publishedAt), color: selectedColor ?? colors.textDim, }; case "version": return { text: release.version, color: selectedColor ?? colors.textBright, attributes: TextAttributes.BOLD, }; case "title": return { text: release.title, color: selectedColor ?? colors.text, }; } }, []); const handleHeaderClick = useCallback((columnId: string) => { setSortPreference((current) => nextChangelogSortPreference(current, columnId)); }, []); const selectRelease = useCallback((release: ChangelogRelease) => { setSelectedReleaseId(release.id); }, []); const footerInfo = useMemo(() => { const segments: PaneFooterSegment[] = []; if (status === "loading") { segments.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }], }); } if (status === "error") { segments.push({ id: "error", parts: [{ text: "error", tone: "warning" }], }); } return segments; }, [status]); // The stack title names the release, so the footer offers only [o]pen. useExternalLinkFooter({ registrationId: "changelog", focused, url: openRelease?.url, info: footerInfo, }); if (releases.length === 0 && (status === "loading" || status === "error")) { return ( ); } return ( focused={focused} detailOpen={!!openRelease} onBack={() => setOpenReleaseId(null)} detailContent={openRelease ? ( ) : ( )} detailTitle={openRelease ? releaseDetailTitle(openRelease) : undefined} selection={{ kind: "id", selectedId: activeSelectedReleaseId, getId: (release) => release.id, onChange: (_id, release) => selectRelease(release), }} onActivate={(release) => setOpenReleaseId(release.id)} onDetailKeyDown={handleDetailKeyDown} rootWidth={width} rootHeight={height} columns={columns} items={sortedReleases} sortColumnId={sortPreference.columnId} sortDirection={sortPreference.direction} onHeaderClick={handleHeaderClick} getItemKey={(release) => release.id} renderCell={renderCell} emptyStateTitle="No changelog entries." showHorizontalScrollbar={false} /> ); } export const changelogModule: PluginModule = { panes: [ { id: "changelog", name: "Changelog", icon: "L", component: ChangelogPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 100, height: 32 }, }, ], paneTemplates: [ { id: "changelog-pane", paneId: "changelog", label: "Changelog", description: "Browse version history and release notes.", keywords: ["changelog", "release", "releases", "version", "updates"], shortcut: { prefix: "CHG" }, createInstance: (_context, options) => { const version = options?.values?.version; return version ? { params: { version } } : {}; }, }, ], };