| @@ -1,5 +1,5 @@ |
| 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; | 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 | import { | 3 | import { |
| 4 | useAtomRefresh, | 4 | useAtomRefresh, |
| 5 | useAtomValue, | 5 | useAtomValue, |
| @@ -8,6 +8,165 @@ import { · 2 unmodified lines |
| 8 | sourcesAtom, | 8 | sourcesAtom, |
| 9 | toolsAtom, | 9 | toolsAtom, |
| 10 | } from "@executor/react"; | 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 | // ── NavItem ────────────────────────────────────────────────────────────── | 171 | // ── NavItem ────────────────────────────────────────────────────────────── |
| 13 | | 172 | |
| @@ -83,6 +242,9 @@ function SidebarContent(props: { · 69 unmodified lines |
| 83 | pathname: string; | 242 | pathname: string; |
| 84 | onNavigate?: () => void; | 243 | onNavigate?: () => void; |
| 85 | showBrand?: boolean; | 244 | showBrand?: boolean; |
| | 245 | updateAvailable: boolean; |
| | 246 | latestVersion: string | null; |
| | 247 | channel: UpdateChannel; |
| 86 | }) { | 248 | }) { |
| 87 | const isHome = props.pathname === "/" || props.pathname === "/tools"; | 249 | const isHome = props.pathname === "/" || props.pathname === "/tools"; |
| 88 | const isSecrets = props.pathname === "/secrets"; | 250 | const isSecrets = props.pathname === "/secrets"; |
| @@ -141,12 +303,16 @@ function SidebarContent(props: { · 52 unmodified lines |
| 141 | <SourceList pathname={props.pathname} onNavigate={props.onNavigate} /> | 303 | <SourceList pathname={props.pathname} onNavigate={props.onNavigate} /> |
| 142 | </nav> | 304 | </nav> |
| 143 | | 305 | |
| | 306 | {props.updateAvailable && props.latestVersion && ( |
| | 307 | <UpdateCard latestVersion={props.latestVersion} channel={props.channel} /> |
| | 308 | )} |
| | 309 | |
| 144 | {/* Footer */} | 310 | {/* Footer */} |
| 145 | <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> | 311 | <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> |
| 146 | <div className="flex items-center justify-between text-[10px] leading-none"> | 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 | <a | 314 | <a |
| 149 | href="https://github.com" | 315 | href={VITE_GITHUB_URL} |
| 150 | target="_blank" | 316 | target="_blank" |
| 151 | rel="noreferrer" | 317 | rel="noreferrer" |
| 152 | className="text-muted-foreground/70 transition-colors hover:text-foreground" | 318 | className="text-muted-foreground/70 transition-colors hover:text-foreground" |
| @@ -172,6 +338,7 @@ export function Shell() { · 19 unmodified lines |
| 172 | const refreshSourceTools = useAtomRefresh( | 338 | const refreshSourceTools = useAtomRefresh( |
| 173 | sourceToolsAtom(currentSourceId ?? "__runtime__"), | 339 | sourceToolsAtom(currentSourceId ?? "__runtime__"), |
| 174 | ); | 340 | ); |
| | 341 | const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); |
| 175 | const lastPathname = useRef(pathname); | 342 | const lastPathname = useRef(pathname); |
| 176 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); | 343 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 177 | if (lastPathname.current !== pathname) { | 344 | if (lastPathname.current !== pathname) { |
| @@ -213,7 +380,12 @@ export function Shell() { · 35 unmodified lines |
| 213 | <div className="flex h-screen overflow-hidden"> | 380 | <div className="flex h-screen overflow-hidden"> |
| 214 | {/* Desktop sidebar */} | 381 | {/* Desktop sidebar */} |
| 215 | <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> | 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 | </aside> | 389 | </aside> |
| 218 | | 390 | |
| 219 | {/* Mobile sidebar overlay */} | 391 | {/* Mobile sidebar overlay */} |
| @@ -250,6 +422,9 @@ export function Shell() { · 30 unmodified lines |
| 250 | pathname={pathname} | 422 | pathname={pathname} |
| 251 | onNavigate={() => setMobileSidebarOpen(false)} | 423 | onNavigate={() => setMobileSidebarOpen(false)} |
| 252 | showBrand={false} | 424 | showBrand={false} |
| | 425 | updateAvailable={updateAvailable} |
| | 426 | latestVersion={latestVersion} |
| | 427 | channel={channel} |
| 253 | /> | 428 | /> |
| 254 | </div> | 429 | </div> |
| 255 | </div> | 430 | </div> |