| @@ -1,5 +1,5 @@ |
| 1 | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | | −import { useEffect, useRef, useState } from "react"; |
| 2 | +import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | 3 | import { |
| 4 | 4 | useAtomRefresh, |
| 5 | 5 | useAtomValue, |
| @@ -8,6 +8,165 @@ import { · 2 unmodified lines |
| 8 | 8 | sourcesAtom, |
| 9 | 9 | toolsAtom, |
| 10 | 10 | } from "@executor/react"; |
| 11 | +import { Button } from "@executor/ui/components/button"; |
| 12 | + |
| 13 | +// ── Env ───────────────────────────────────────────────────────────────── |
| 14 | + |
| 15 | +type AppMetaEnv = { |
| 16 | + readonly VITE_APP_VERSION: string; |
| 17 | + readonly VITE_GITHUB_URL: string; |
| 18 | +}; |
| 19 | + |
| 20 | +const { VITE_APP_VERSION, VITE_GITHUB_URL } = (import.meta as ImportMeta & { |
| 21 | + readonly env: AppMetaEnv; |
| 22 | +}).env; |
| 23 | + |
| 24 | +// ── Version helpers ───────────────────────────────────────────────────── |
| 25 | + |
| 26 | +type UpdateChannel = "latest" | "beta"; |
| 27 | + |
| 28 | +const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags"; |
| 29 | + |
| 30 | +type ParsedVersion = { |
| 31 | + readonly major: number; |
| 32 | + readonly minor: number; |
| 33 | + readonly patch: number; |
| 34 | + readonly prerelease: ReadonlyArray<string | number> | null; |
| 35 | +}; |
| 36 | + |
| 37 | +const semverPattern = |
| 38 | + /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; |
| 39 | + |
| 40 | +const resolveUpdateChannel = (version: string): UpdateChannel => |
| 41 | + version.includes("-beta.") ? "beta" : "latest"; |
| 42 | + |
| 43 | +const parseVersion = (version: string): ParsedVersion | null => { |
| 44 | + const match = version.trim().match(semverPattern); |
| 45 | + if (!match?.groups) return null; |
| 46 | + return { |
| 47 | + major: Number(match.groups.major), |
| 48 | + minor: Number(match.groups.minor), |
| 49 | + patch: Number(match.groups.patch), |
| 50 | + prerelease: match.groups.prerelease |
| 51 | + ? match.groups.prerelease.split(".").map((id) => |
| 52 | + /^\d+$/.test(id) ? Number(id) : id, |
| 53 | + ) |
| 54 | + : null, |
| 55 | + }; |
| 56 | +}; |
| 57 | + |
| 58 | +const comparePrereleaseIdentifiers = ( |
| 59 | + left: ReadonlyArray<string | number> | null, |
| 60 | + right: ReadonlyArray<string | number> | null, |
| 61 | +): number => { |
| 62 | + if (left === null && right === null) return 0; |
| 63 | + if (left === null) return 1; |
| 64 | + if (right === null) return -1; |
| 65 | + const max = Math.max(left.length, right.length); |
| 66 | + for (let i = 0; i < max; i++) { |
| 67 | + const l = left[i]; |
| 68 | + const r = right[i]; |
| 69 | + if (l === undefined) return -1; |
| 70 | + if (r === undefined) return 1; |
| 71 | + if (l === r) continue; |
| 72 | + if (typeof l === "number" && typeof r === "number") return l < r ? -1 : 1; |
| 73 | + if (typeof l === "number") return -1; |
| 74 | + if (typeof r === "number") return 1; |
| 75 | + return l < r ? -1 : 1; |
| 76 | + } |
| 77 | + return 0; |
| 78 | +}; |
| 79 | + |
| 80 | +const compareVersions = (left: string, right: string): number | null => { |
| 81 | + const lv = parseVersion(left); |
| 82 | + const rv = parseVersion(right); |
| 83 | + if (!lv || !rv) return null; |
| 84 | + if (lv.major !== rv.major) return lv.major < rv.major ? -1 : 1; |
| 85 | + if (lv.minor !== rv.minor) return lv.minor < rv.minor ? -1 : 1; |
| 86 | + if (lv.patch !== rv.patch) return lv.patch < rv.patch ? -1 : 1; |
| 87 | + return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease); |
| 88 | +}; |
| 89 | + |
| 90 | +// ── useLatestVersion ──────────────────────────────────────────────────── |
| 91 | + |
| 92 | +function useLatestVersion(currentVersion: string) { |
| 93 | + const channel = resolveUpdateChannel(currentVersion); |
| 94 | + const [latestVersion, setLatestVersion] = useState<string | null>(null); |
| 95 | + |
| 96 | + useEffect(() => { |
| 97 | + let cancelled = false; |
| 98 | + fetch(EXECUTOR_DIST_TAGS_PATH) |
| 99 | + .then(async (res) => { |
| 100 | + if (!res.ok) throw new Error(`Failed to load dist tags: ${res.status}`); |
| 101 | + return res.json() as Promise<Partial<Record<UpdateChannel, string>>>; |
| 102 | + }) |
| 103 | + .then((data) => { |
| 104 | + if (!cancelled) setLatestVersion(data[channel] ?? null); |
| 105 | + }) |
| 106 | + .catch(() => {}); |
| 107 | + return () => { cancelled = true; }; |
| 108 | + }, [channel]); |
| 109 | + |
| 110 | + const updateAvailable = |
| 111 | + latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1; |
| 112 | + |
| 113 | + return { latestVersion, updateAvailable, channel }; |
| 114 | +} |
| 115 | + |
| 116 | +// ── UpdateCard ────────────────────────────────────────────────────────── |
| 117 | + |
| 118 | +function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { |
| 119 | + const command = `npm i -g executor@${props.channel}`; |
| 120 | + const [copied, setCopied] = useState(false); |
| 121 | + |
| 122 | + const handleCopy = useCallback(() => { |
| 123 | + void navigator.clipboard.writeText(command).then(() => { |
| 124 | + setCopied(true); |
| 125 | + setTimeout(() => setCopied(false), 1500); |
| 126 | + }); |
| 127 | + }, [command]); |
| 128 | + |
| 129 | + return ( |
| 130 | + <div className="mx-2 mb-2 rounded-xl border border-primary/25 bg-primary/[0.06] p-3"> |
| 131 | + <div className="flex items-center gap-2"> |
| 132 | + <div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/15"> |
| 133 | + <svg viewBox="0 0 16 16" fill="none" className="size-3 text-primary"> |
| 134 | + <path d="M8 3v7M5 7l3 3 3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> |
| 135 | + <path d="M3 12h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> |
| 136 | + </svg> |
| 137 | + </div> |
| 138 | + <div className="min-w-0"> |
| 139 | + <p className="text-[11px] font-semibold text-foreground">Update available</p> |
| 140 | + <p className="text-[10px] text-muted-foreground"> |
| 141 | + v{props.latestVersion} |
| 142 | + </p> |
| 143 | + </div> |
| 144 | + </div> |
| 145 | + <Button |
| 146 | + type="button" |
| 147 | + variant="outline" |
| 148 | + onClick={handleCopy} |
| 149 | + className="mt-2.5 flex w-full items-center justify-between gap-2 rounded-lg border-border/60 bg-background/50 px-2.5 py-1.5 text-left hover:bg-background/80" |
| 150 | + > |
| 151 | + <code className="truncate font-mono text-[10px] text-sidebar-foreground"> |
| 152 | + {command} |
| 153 | + </code> |
| 154 | + <span className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"> |
| 155 | + {copied ? ( |
| 156 | + <svg viewBox="0 0 16 16" fill="none" className="size-3 text-primary"> |
| 157 | + <path d="M3 8.5l3.5 3.5L13 4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> |
| 158 | + </svg> |
| 159 | + ) : ( |
| 160 | + <svg viewBox="0 0 16 16" fill="none" className="size-3"> |
| 161 | + <rect x="5" y="5" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.2" /> |
| 162 | + <path d="M3 11V3.5A.5.5 0 013.5 3H11" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> |
| 163 | + </svg> |
| 164 | + )} |
| 165 | + </span> |
| 166 | + </Button> |
| 167 | + </div> |
| 168 | + ); |
| 169 | +} |
| 11 | 170 | |
| 12 | 171 | // ── NavItem ────────────────────────────────────────────────────────────── |
| 13 | 172 | |
| @@ -83,6 +242,9 @@ function SidebarContent(props: { · 69 unmodified lines |
| 83 | 242 | pathname: string; |
| 84 | 243 | onNavigate?: () => void; |
| 85 | 244 | showBrand?: boolean; |
| 245 | + updateAvailable: boolean; |
| 246 | + latestVersion: string | null; |
| 247 | + channel: UpdateChannel; |
| 86 | 248 | }) { |
| 87 | 249 | const isHome = props.pathname === "/" || props.pathname === "/tools"; |
| 88 | 250 | const isSecrets = props.pathname === "/secrets"; |
| @@ -141,12 +303,16 @@ function SidebarContent(props: { · 52 unmodified lines |
| 141 | 303 | <SourceList pathname={props.pathname} onNavigate={props.onNavigate} /> |
| 142 | 304 | </nav> |
| 143 | 305 | |
| 306 | + {props.updateAvailable && props.latestVersion && ( |
| 307 | + <UpdateCard latestVersion={props.latestVersion} channel={props.channel} /> |
| 308 | + )} |
| 309 | + |
| 144 | 310 | {/* Footer */} |
| 145 | 311 | <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> |
| 146 | 312 | <div className="flex items-center justify-between text-[10px] leading-none"> |
| 147 | | − <span className="text-muted-foreground/70 tabular-nums">v4.0.0-dev</span> |
| 313 | + <span className="text-muted-foreground/70 tabular-nums">v{VITE_APP_VERSION}</span> |
| 148 | 314 | <a |
| 149 | | − href="https://github.com" |
| 315 | + href={VITE_GITHUB_URL} |
| 150 | 316 | target="_blank" |
| 151 | 317 | rel="noreferrer" |
| 152 | 318 | className="text-muted-foreground/70 transition-colors hover:text-foreground" |
| @@ -172,6 +338,7 @@ export function Shell() { · 19 unmodified lines |
| 172 | 338 | const refreshSourceTools = useAtomRefresh( |
| 173 | 339 | sourceToolsAtom(currentSourceId ?? "__runtime__"), |
| 174 | 340 | ); |
| 341 | + const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); |
| 175 | 342 | const lastPathname = useRef(pathname); |
| 176 | 343 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 177 | 344 | if (lastPathname.current !== pathname) { |
| @@ -213,7 +380,12 @@ export function Shell() { · 35 unmodified lines |
| 213 | 380 | <div className="flex h-screen overflow-hidden"> |
| 214 | 381 | {/* Desktop sidebar */} |
| 215 | 382 | <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> |
| 216 | | − <SidebarContent pathname={pathname} /> |
| 383 | + <SidebarContent |
| 384 | + pathname={pathname} |
| 385 | + updateAvailable={updateAvailable} |
| 386 | + latestVersion={latestVersion} |
| 387 | + channel={channel} |
| 388 | + /> |
| 217 | 389 | </aside> |
| 218 | 390 | |
| 219 | 391 | {/* Mobile sidebar overlay */} |
| @@ -250,6 +422,9 @@ export function Shell() { · 30 unmodified lines |
| 250 | 422 | pathname={pathname} |
| 251 | 423 | onNavigate={() => setMobileSidebarOpen(false)} |
| 252 | 424 | showBrand={false} |
| 425 | + updateAvailable={updateAvailable} |
| 426 | + latestVersion={latestVersion} |
| 427 | + channel={channel} |
| 253 | 428 | /> |
| 254 | 429 | </div> |
| 255 | 430 | </div> |