| author | |
| committer | |
| log | f0b57ca2313bd7971cefb366d3b3808152b014fc |
| tree | d67e164093ddf27a8bfe79f0b46385326142d393 |
| parent | 2db08457f2f4eaaa713a067b2ea480ca2b583025 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>11 files changed, 2566 insertions(+), 134 deletions(-)
apps/web/package.json+3| ... | ... | @@ -23,6 +23,7 @@ |
| 23 | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | 24 | "@legendapp/list": "catalog:", |
| 25 | 25 | "@lexical/react": "^0.41.0", |
| 26 | "@noble/hashes": "catalog:", | |
| 26 | 27 | "@pierre/diffs": "catalog:", |
| 27 | 28 | "@pierre/trees": "1.0.0-beta.4", |
| 28 | 29 | "@t3tools/client-runtime": "workspace:*", |
| ... | ... | @@ -34,6 +35,8 @@ |
| 34 | 35 | "culori": "^4.0.2", |
| 35 | 36 | "effect": "catalog:", |
| 36 | 37 | "jose": "catalog:", |
| 38 | "jsonc-parser": "3.3.1", | |
| 39 | "jszip": "3.10.1", | |
| 37 | 40 | "lexical": "^0.41.0", |
| 38 | 41 | "lucide-react": "^0.564.0", |
| 39 | 42 | "react": "19.2.6", |
apps/web/src/components/settings/ThemeEditorPanel.tsx+14-23| ... | ... | @@ -610,7 +610,7 @@ export function ThemeEditorPanel({ |
| 610 | 610 | [activeAppearance, editingTheme, selectedRole], |
| 611 | 611 | ); |
| 612 | 612 | |
| 613 | const handleSubmit = useCallback(() => { | |
| 613 | const handleSubmit = () => { | |
| 614 | 614 | if (!name.trim()) { |
| 615 | 615 | setError("Name your theme first."); |
| 616 | 616 | return; |
| ... | ... | @@ -645,8 +645,8 @@ export function ThemeEditorPanel({ |
| 645 | 645 | return; |
| 646 | 646 | } |
| 647 | 647 | mergedAppearance = editedModes[0] ?? null; |
| 648 | savedTheme = updateCustomTheme( | |
| 649 | parseThemeFile({ | |
| 648 | savedTheme = updateCustomTheme({ | |
| 649 | ...parseThemeFile({ | |
| 650 | 650 | version: THEME_FILE_VERSION, |
| 651 | 651 | id: mergeTarget.id, |
| 652 | 652 | name: mergeTarget.label, |
| ... | ... | @@ -658,7 +658,8 @@ export function ThemeEditorPanel({ |
| 658 | 658 | }, |
| 659 | 659 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 660 | 660 | }), |
| 661 | ); | |
| 661 | ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), | |
| 662 | }); | |
| 662 | 663 | retiredTheme = editingTheme; |
| 663 | 664 | try { |
| 664 | 665 | removeCustomTheme(editingTheme.id); |
| ... | ... | @@ -676,8 +677,8 @@ export function ThemeEditorPanel({ |
| 676 | 677 | } else if (editingTheme) { |
| 677 | 678 | const baseAppearance = editingTheme.appearance; |
| 678 | 679 | const variantAppearance = baseAppearance === "light" ? "dark" : "light"; |
| 679 | savedTheme = updateCustomTheme( | |
| 680 | parseThemeFile({ | |
| 680 | savedTheme = updateCustomTheme({ | |
| 681 | ...parseThemeFile({ | |
| 681 | 682 | version: THEME_FILE_VERSION, |
| 682 | 683 | id: editingTheme.id, |
| 683 | 684 | name, |
| ... | ... | @@ -688,7 +689,8 @@ export function ThemeEditorPanel({ |
| 688 | 689 | : {}), |
| 689 | 690 | ...(isAdvanced ? {} : { managed: true }), |
| 690 | 691 | }), |
| 691 | ); | |
| 692 | ...(editingTheme.collection ? { collection: editingTheme.collection } : {}), | |
| 693 | }); | |
| 692 | 694 | } else if (mergeTarget) { |
| 693 | 695 | if (takenAppearances.includes(activeAppearance)) { |
| 694 | 696 | setError( |
| ... | ... | @@ -701,8 +703,8 @@ export function ThemeEditorPanel({ |
| 701 | 703 | // survives when every palette in the theme came from the guided |
| 702 | 704 | // editor. |
| 703 | 705 | mergedAppearance = activeAppearance; |
| 704 | savedTheme = updateCustomTheme( | |
| 705 | parseThemeFile({ | |
| 706 | savedTheme = updateCustomTheme({ | |
| 707 | ...parseThemeFile({ | |
| 706 | 708 | version: THEME_FILE_VERSION, |
| 707 | 709 | id: mergeTarget.id, |
| 708 | 710 | name: mergeTarget.label, |
| ... | ... | @@ -714,7 +716,8 @@ export function ThemeEditorPanel({ |
| 714 | 716 | }, |
| 715 | 717 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 716 | 718 | }), |
| 717 | ); | |
| 719 | ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), | |
| 720 | }); | |
| 718 | 721 | } else { |
| 719 | 722 | savedTheme = installCustomTheme( |
| 720 | 723 | parseThemeFile({ |
| ... | ... | @@ -762,19 +765,7 @@ export function ThemeEditorPanel({ |
| 762 | 765 | : "Could not create the theme.", |
| 763 | 766 | ); |
| 764 | 767 | } |
| 765 | }, [ | |
| 766 | activeAppearance, | |
| 767 | colorsByAppearance, | |
| 768 | editingTheme, | |
| 769 | isAdvanced, | |
| 770 | isEditing, | |
| 771 | mergeTarget, | |
| 772 | name, | |
| 773 | onOpenChange, | |
| 774 | onSaved, | |
| 775 | simpleColorsDirtyByAppearance, | |
| 776 | takenAppearances, | |
| 777 | ]); | |
| 768 | }; | |
| 778 | 769 | |
| 779 | 770 | const renderNameField = () => ( |
| 780 | 771 | <label className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3"> |
apps/web/src/components/settings/ThemeImportDialog.tsx+26-2| ... | ... | @@ -28,6 +28,7 @@ import { |
| 28 | 28 | DialogPopup, |
| 29 | 29 | DialogTitle, |
| 30 | 30 | } from "../ui/dialog"; |
| 31 | import { ThemeSearchSection } from "./ThemeSearchSection"; | |
| 31 | 32 | |
| 32 | 33 | /** |
| 33 | 34 | * A full theme export is a few KB, so anything past this is not a theme file. |
| ... | ... | @@ -360,9 +361,16 @@ export function ThemeImportDialog({ |
| 360 | 361 | : null; |
| 361 | 362 | for (const theme of conflicts) { |
| 362 | 363 | try { |
| 364 | const existingTheme = | |
| 365 | mode === "update" | |
| 366 | ? getCustomThemes().find((candidate) => candidate.id === theme.id) | |
| 367 | : undefined; | |
| 368 | const themeToUpdate = existingTheme?.collection | |
| 369 | ? { ...theme, collection: existingTheme.collection } | |
| 370 | : theme; | |
| 363 | 371 | resolved.push( |
| 364 | 372 | mode === "update" |
| 365 | ? updateCustomTheme(theme) | |
| 373 | ? updateCustomTheme(themeToUpdate) | |
| 366 | 374 | : installCustomTheme(versionedCopy(theme, preferredName)), |
| 367 | 375 | ); |
| 368 | 376 | } catch (cause) { |
| ... | ... | @@ -426,7 +434,23 @@ export function ThemeImportDialog({ |
| 426 | 434 | <DialogHeader> |
| 427 | 435 | <DialogTitle>Add a theme</DialogTitle> |
| 428 | 436 | </DialogHeader> |
| 429 | <DialogPanel className="space-y-4"> | |
| 437 | <DialogPanel className="space-y-5"> | |
| 438 | <ThemeSearchSection | |
| 439 | onInstalled={(themes, context) => { | |
| 440 | onImportedMany(themes, context); | |
| 441 | onOpenChange(false); | |
| 442 | }} | |
| 443 | open={open} | |
| 444 | /> | |
| 445 | ||
| 446 | <div className="flex items-center gap-3" aria-hidden> | |
| 447 | <div className="h-px flex-1 bg-border" /> | |
| 448 | <span className="text-muted-foreground text-[11px] uppercase tracking-wider"> | |
| 449 | or import a file | |
| 450 | </span> | |
| 451 | <div className="h-px flex-1 bg-border" /> | |
| 452 | </div> | |
| 453 | ||
| 430 | 454 | {(() => { |
| 431 | 455 | const dropHandlers = { |
| 432 | 456 | onDragEnter: (event: DragEvent<HTMLDivElement>) => { |
apps/web/src/components/settings/ThemeSearchSection.tsx created+389| ... | ... | @@ -0,0 +1,389 @@ |
| 1 | import { | |
| 2 | ExternalLinkIcon, | |
| 3 | PaletteIcon, | |
| 4 | PlusIcon, | |
| 5 | RefreshCwIcon, | |
| 6 | SearchIcon, | |
| 7 | ShieldCheckIcon, | |
| 8 | } from "lucide-react"; | |
| 9 | import type { FormEvent } from "react"; | |
| 10 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 11 | import { | |
| 12 | importOpenVsxThemeExtension, | |
| 13 | searchOpenVsxThemes, | |
| 14 | type OpenVsxThemeExtension, | |
| 15 | type OpenVsxThemeSort, | |
| 16 | } from "../../openVsxThemes"; | |
| 17 | import { | |
| 18 | getCustomThemes, | |
| 19 | getStoredCustomThemeCollection, | |
| 20 | replaceCustomThemeCollection, | |
| 21 | type ThemeDefinition, | |
| 22 | } from "../../themePalette"; | |
| 23 | import { | |
| 24 | AlertDialog, | |
| 25 | AlertDialogClose, | |
| 26 | AlertDialogDescription, | |
| 27 | AlertDialogFooter, | |
| 28 | AlertDialogHeader, | |
| 29 | AlertDialogPopup, | |
| 30 | AlertDialogTitle, | |
| 31 | } from "../ui/alert-dialog"; | |
| 32 | import { Button } from "../ui/button"; | |
| 33 | import { Input } from "../ui/input"; | |
| 34 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 35 | import { Spinner } from "../ui/spinner"; | |
| 36 | ||
| 37 | const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, { | |
| 38 | notation: "compact", | |
| 39 | maximumFractionDigits: 1, | |
| 40 | }); | |
| 41 | const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"]; | |
| 42 | const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = [ | |
| 43 | { value: "downloadCount", label: "Most downloaded" }, | |
| 44 | { value: "rating", label: "Best rated" }, | |
| 45 | { value: "timestamp", label: "Newest" }, | |
| 46 | { value: "relevance", label: "Most relevant" }, | |
| 47 | ]; | |
| 48 | ||
| 49 | function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { | |
| 50 | const [failed, setFailed] = useState(false); | |
| 51 | ||
| 52 | return ( | |
| 53 | <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground"> | |
| 54 | {extension.iconUrl && !failed ? ( | |
| 55 | <img | |
| 56 | alt="" | |
| 57 | className="size-full object-cover" | |
| 58 | loading="lazy" | |
| 59 | referrerPolicy="no-referrer" | |
| 60 | src={extension.iconUrl} | |
| 61 | onError={() => setFailed(true)} | |
| 62 | /> | |
| 63 | ) : ( | |
| 64 | <PaletteIcon className="size-4" /> | |
| 65 | )} | |
| 66 | </div> | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | export function ThemeSearchSection({ | |
| 71 | open, | |
| 72 | onInstalled, | |
| 73 | }: { | |
| 74 | open: boolean; | |
| 75 | onInstalled: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void; | |
| 76 | }) { | |
| 77 | const [query, setQuery] = useState(""); | |
| 78 | const [sortBy, setSortBy] = useState<OpenVsxThemeSort>("downloadCount"); | |
| 79 | const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null); | |
| 80 | const [error, setError] = useState<string | null>(null); | |
| 81 | const [isSearching, setIsSearching] = useState(false); | |
| 82 | const [installingId, setInstallingId] = useState<string | null>(null); | |
| 83 | const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null); | |
| 84 | const requestRef = useRef<AbortController | null>(null); | |
| 85 | ||
| 86 | useEffect(() => { | |
| 87 | requestRef.current?.abort(); | |
| 88 | requestRef.current = null; | |
| 89 | if (open) { | |
| 90 | setQuery(""); | |
| 91 | setSortBy("downloadCount"); | |
| 92 | setResults(null); | |
| 93 | setError(null); | |
| 94 | setIsSearching(false); | |
| 95 | setInstallingId(null); | |
| 96 | setPendingUpdate(null); | |
| 97 | } | |
| 98 | return () => { | |
| 99 | requestRef.current?.abort(); | |
| 100 | requestRef.current = null; | |
| 101 | }; | |
| 102 | }, [open]); | |
| 103 | ||
| 104 | const runSearch = useCallback( | |
| 105 | async (searchText: string, nextSort = sortBy) => { | |
| 106 | const trimmed = searchText.trim(); | |
| 107 | if (!trimmed) return; | |
| 108 | requestRef.current?.abort(); | |
| 109 | const controller = new AbortController(); | |
| 110 | requestRef.current = controller; | |
| 111 | setQuery(trimmed); | |
| 112 | setIsSearching(true); | |
| 113 | setError(null); | |
| 114 | setResults(null); | |
| 115 | try { | |
| 116 | const nextResults = await searchOpenVsxThemes(trimmed, { | |
| 117 | signal: controller.signal, | |
| 118 | sortBy: nextSort, | |
| 119 | }); | |
| 120 | if (!controller.signal.aborted) setResults(nextResults); | |
| 121 | } catch (cause) { | |
| 122 | if (!controller.signal.aborted) { | |
| 123 | setError(cause instanceof Error ? cause.message : "Open VSX search failed."); | |
| 124 | } | |
| 125 | } | |
| 126 | if (requestRef.current === controller) { | |
| 127 | requestRef.current = null; | |
| 128 | setIsSearching(false); | |
| 129 | } | |
| 130 | }, | |
| 131 | [sortBy], | |
| 132 | ); | |
| 133 | ||
| 134 | const handleSearch = useCallback( | |
| 135 | (event: FormEvent<HTMLFormElement>) => { | |
| 136 | event.preventDefault(); | |
| 137 | void runSearch(query); | |
| 138 | }, | |
| 139 | [query, runSearch], | |
| 140 | ); | |
| 141 | ||
| 142 | const handleSortChange = useCallback( | |
| 143 | (value: OpenVsxThemeSort | null) => { | |
| 144 | const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value; | |
| 145 | if (!nextSort) return; | |
| 146 | setSortBy(nextSort); | |
| 147 | if ((results !== null || isSearching) && query.trim()) { | |
| 148 | void runSearch(query, nextSort); | |
| 149 | } | |
| 150 | }, | |
| 151 | [isSearching, query, results, runSearch], | |
| 152 | ); | |
| 153 | ||
| 154 | const handleInstall = useCallback( | |
| 155 | async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => { | |
| 156 | setError(null); | |
| 157 | let installedCollection: ReadonlyArray<ThemeDefinition>; | |
| 158 | try { | |
| 159 | installedCollection = getStoredCustomThemeCollection(extension.collectionId); | |
| 160 | } catch (cause) { | |
| 161 | setError(cause instanceof Error ? cause.message : "Installed themes could not be read."); | |
| 162 | return; | |
| 163 | } | |
| 164 | const updated = installedCollection.length > 0; | |
| 165 | if (updated && !allowUpdate) { | |
| 166 | setPendingUpdate(extension); | |
| 167 | return; | |
| 168 | } | |
| 169 | ||
| 170 | requestRef.current?.abort(); | |
| 171 | const controller = new AbortController(); | |
| 172 | requestRef.current = controller; | |
| 173 | setInstallingId(extension.id); | |
| 174 | try { | |
| 175 | const themes = await importOpenVsxThemeExtension(extension, controller.signal); | |
| 176 | if (!controller.signal.aborted) { | |
| 177 | const imported = replaceCustomThemeCollection(extension.collectionId, themes, { | |
| 178 | expectedCollection: installedCollection, | |
| 179 | }); | |
| 180 | onInstalled(imported, { updated }); | |
| 181 | } | |
| 182 | } catch (cause) { | |
| 183 | if (!controller.signal.aborted) { | |
| 184 | setError(cause instanceof Error ? cause.message : "That theme could not be added."); | |
| 185 | } | |
| 186 | } | |
| 187 | if (requestRef.current === controller) { | |
| 188 | requestRef.current = null; | |
| 189 | setInstallingId(null); | |
| 190 | } | |
| 191 | }, | |
| 192 | [onInstalled], | |
| 193 | ); | |
| 194 | ||
| 195 | return ( | |
| 196 | <section className="space-y-3" aria-labelledby="theme-search-heading"> | |
| 197 | <div> | |
| 198 | <h3 className="text-sm font-medium" id="theme-search-heading"> | |
| 199 | Search community themes | |
| 200 | </h3> | |
| 201 | <p className="mt-0.5 text-muted-foreground text-xs"> | |
| 202 | Find open-source themes from Open VSX. | |
| 203 | </p> | |
| 204 | </div> | |
| 205 | <form className="relative flex gap-2" onSubmit={handleSearch}> | |
| 206 | <SearchIcon className="pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground" /> | |
| 207 | <Input | |
| 208 | aria-label="Search Open VSX themes" | |
| 209 | className="rounded-xl [&_input]:pl-9" | |
| 210 | autoFocus | |
| 211 | onChange={(event) => setQuery(event.currentTarget.value)} | |
| 212 | placeholder="try dracula, nord, catppuccin..." | |
| 213 | size="lg" | |
| 214 | type="search" | |
| 215 | value={query} | |
| 216 | /> | |
| 217 | <Button | |
| 218 | disabled={!query.trim() || isSearching || installingId !== null} | |
| 219 | type="submit" | |
| 220 | variant="outline" | |
| 221 | > | |
| 222 | {isSearching ? <Spinner /> : <SearchIcon />} | |
| 223 | Search | |
| 224 | </Button> | |
| 225 | </form> | |
| 226 | ||
| 227 | {!isSearching ? ( | |
| 228 | <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> | |
| 229 | <div className="flex min-w-0 flex-wrap items-center gap-1.5"> | |
| 230 | <p className="text-muted-foreground text-xs">Popular</p> | |
| 231 | {SUGGESTED_SEARCHES.map((suggestion) => ( | |
| 232 | <Button | |
| 233 | key={suggestion} | |
| 234 | size="xs" | |
| 235 | variant="ghost" | |
| 236 | onClick={() => void runSearch(suggestion)} | |
| 237 | > | |
| 238 | {suggestion} | |
| 239 | </Button> | |
| 240 | ))} | |
| 241 | </div> | |
| 242 | {results && results.length > 0 ? ( | |
| 243 | <div className="flex shrink-0 items-center justify-end gap-2"> | |
| 244 | <p className="text-muted-foreground text-xs">Sort</p> | |
| 245 | <Select | |
| 246 | disabled={installingId !== null} | |
| 247 | value={sortBy} | |
| 248 | onValueChange={handleSortChange} | |
| 249 | > | |
| 250 | <SelectTrigger size="sm" className="w-40" aria-label="Sort themes"> | |
| 251 | <SelectValue> | |
| 252 | {SORT_OPTIONS.find((option) => option.value === sortBy)?.label} | |
| 253 | </SelectValue> | |
| 254 | </SelectTrigger> | |
| 255 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 256 | {SORT_OPTIONS.map((option) => ( | |
| 257 | <SelectItem key={option.value} hideIndicator value={option.value}> | |
| 258 | {option.label} | |
| 259 | </SelectItem> | |
| 260 | ))} | |
| 261 | </SelectPopup> | |
| 262 | </Select> | |
| 263 | </div> | |
| 264 | ) : null} | |
| 265 | </div> | |
| 266 | ) : null} | |
| 267 | ||
| 268 | <div className="sr-only" role="status"> | |
| 269 | {isSearching | |
| 270 | ? "Finding themes..." | |
| 271 | : results | |
| 272 | ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.` | |
| 273 | : ""} | |
| 274 | </div> | |
| 275 | ||
| 276 | {error ? ( | |
| 277 | <div | |
| 278 | aria-live="polite" | |
| 279 | className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm" | |
| 280 | > | |
| 281 | {error} | |
| 282 | </div> | |
| 283 | ) : null} | |
| 284 | ||
| 285 | {isSearching ? ( | |
| 286 | <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm"> | |
| 287 | <Spinner /> Finding themes... | |
| 288 | </div> | |
| 289 | ) : null} | |
| 290 | ||
| 291 | {results ? ( | |
| 292 | results.length === 0 ? ( | |
| 293 | <div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center"> | |
| 294 | <p className="text-sm font-medium">No supported open-source themes found</p> | |
| 295 | <p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p> | |
| 296 | </div> | |
| 297 | ) : ( | |
| 298 | <div className="grid gap-2 sm:grid-cols-2"> | |
| 299 | {results.map((extension) => { | |
| 300 | const isInstalling = installingId === extension.id; | |
| 301 | const isInstalled = getCustomThemes().some( | |
| 302 | (theme) => theme.collection?.id === extension.collectionId, | |
| 303 | ); | |
| 304 | const action = isInstalled ? "Update" : "Add"; | |
| 305 | const progressAction = isInstalled ? "Updating" : "Adding"; | |
| 306 | return ( | |
| 307 | <article | |
| 308 | className="group flex min-w-0 flex-col gap-3 rounded-xl border border-border/70 bg-card/60 p-3 transition-colors hover:bg-accent/20" | |
| 309 | key={extension.id} | |
| 310 | > | |
| 311 | <div className="flex min-w-0 gap-3"> | |
| 312 | <ThemeExtensionIcon key={extension.iconUrl} extension={extension} /> | |
| 313 | <div className="min-w-0 flex-1"> | |
| 314 | <h4 className="truncate text-sm font-medium">{extension.name}</h4> | |
| 315 | <p className="truncate text-muted-foreground text-xs"> | |
| 316 | {extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "} | |
| 317 | downloads | |
| 318 | </p> | |
| 319 | </div> | |
| 320 | </div> | |
| 321 | <p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4"> | |
| 322 | {extension.description || "A community color theme for your editor."} | |
| 323 | </p> | |
| 324 | <div className="mt-auto flex items-center justify-between gap-2"> | |
| 325 | <div className="flex min-w-0 items-center gap-2"> | |
| 326 | <span className="inline-flex items-center gap-1 text-muted-foreground text-[11px]"> | |
| 327 | <ShieldCheckIcon className="size-3" /> {extension.license} | |
| 328 | </span> | |
| 329 | {extension.sourceUrl ? ( | |
| 330 | <a | |
| 331 | aria-label={`View source for ${extension.name}`} | |
| 332 | className="inline-flex items-center gap-1 text-muted-foreground text-[11px] hover:text-foreground" | |
| 333 | href={extension.sourceUrl} | |
| 334 | rel="noreferrer" | |
| 335 | target="_blank" | |
| 336 | > | |
| 337 | Source <ExternalLinkIcon className="size-3" /> | |
| 338 | </a> | |
| 339 | ) : null} | |
| 340 | </div> | |
| 341 | <Button | |
| 342 | aria-label={`${isInstalling ? progressAction : action} ${extension.name}`} | |
| 343 | disabled={installingId !== null} | |
| 344 | size="xs" | |
| 345 | variant="outline" | |
| 346 | onClick={() => void handleInstall(extension, false)} | |
| 347 | > | |
| 348 | {isInstalling ? <Spinner /> : isInstalled ? <RefreshCwIcon /> : <PlusIcon />} | |
| 349 | {isInstalling ? `${progressAction}...` : action} | |
| 350 | </Button> | |
| 351 | </div> | |
| 352 | </article> | |
| 353 | ); | |
| 354 | })} | |
| 355 | </div> | |
| 356 | ) | |
| 357 | ) : null} | |
| 358 | ||
| 359 | <AlertDialog | |
| 360 | open={pendingUpdate !== null} | |
| 361 | onOpenChange={(nextOpen) => { | |
| 362 | if (!nextOpen) setPendingUpdate(null); | |
| 363 | }} | |
| 364 | > | |
| 365 | <AlertDialogPopup> | |
| 366 | <AlertDialogHeader> | |
| 367 | <AlertDialogTitle>Update “{pendingUpdate?.name}”?</AlertDialogTitle> | |
| 368 | <AlertDialogDescription> | |
| 369 | This replaces its installed variants, including any local edits. Variants no longer in | |
| 370 | the extension will be removed. | |
| 371 | </AlertDialogDescription> | |
| 372 | </AlertDialogHeader> | |
| 373 | <AlertDialogFooter> | |
| 374 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 375 | <Button | |
| 376 | onClick={() => { | |
| 377 | const extension = pendingUpdate; | |
| 378 | setPendingUpdate(null); | |
| 379 | if (extension) void handleInstall(extension, true); | |
| 380 | }} | |
| 381 | > | |
| 382 | Update theme | |
| 383 | </Button> | |
| 384 | </AlertDialogFooter> | |
| 385 | </AlertDialogPopup> | |
| 386 | </AlertDialog> | |
| 387 | </section> | |
| 388 | ); | |
| 389 | } |
apps/web/src/components/settings/ThemeSettings.tsx+439-96| ... | ... | @@ -1,17 +1,20 @@ |
| 1 | 1 | import { |
| 2 | CheckIcon, | |
| 2 | 3 | CopyIcon, |
| 3 | 4 | DownloadIcon, |
| 5 | MoonIcon, | |
| 4 | 6 | PenLineIcon, |
| 5 | 7 | PlusIcon, |
| 8 | SunIcon, | |
| 6 | 9 | Trash2Icon, |
| 7 | 10 | UploadIcon, |
| 8 | 11 | } from "lucide-react"; |
| 9 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 12 | import { useCallback, useEffect, useState, type ReactElement } from "react"; | |
| 10 | 13 | import { cn } from "../../lib/utils"; |
| 11 | 14 | import { |
| 12 | 15 | getThemeDefinition, |
| 13 | 16 | getThemeModes, |
| 14 | removeCustomTheme, | |
| 17 | removeCustomThemes, | |
| 15 | 18 | serializeThemeFile, |
| 16 | 19 | type ThemeAppearance, |
| 17 | 20 | type ThemeDefinition, |
| ... | ... | @@ -41,6 +44,7 @@ import { |
| 41 | 44 | getThemeCardDefinition, |
| 42 | 45 | previewColorsOf, |
| 43 | 46 | ThemePreviewCircles, |
| 47 | ThemePreviewCircle, | |
| 44 | 48 | type ThemeCardDefinition, |
| 45 | 49 | type ThemeMode, |
| 46 | 50 | } from "./ThemePreviewCircles"; |
| ... | ... | @@ -54,6 +58,21 @@ const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [ |
| 54 | 58 | IRIS_THEME, |
| 55 | 59 | ]; |
| 56 | 60 | |
| 61 | function collectionVariantLabels(themes: ReadonlyArray<ThemeDefinition>): ReadonlyArray<string> { | |
| 62 | if (themes.length === 0) return []; | |
| 63 | const words = themes.map((theme) => theme.label.trim().split(/\s+/)); | |
| 64 | const firstWords = words[0]!; | |
| 65 | const sharedWordCount = firstWords.findIndex((word, index) => | |
| 66 | words.some((labelWords) => labelWords[index]?.toLocaleLowerCase() !== word.toLocaleLowerCase()), | |
| 67 | ); | |
| 68 | const prefixLength = sharedWordCount === -1 ? firstWords.length - 1 : sharedWordCount; | |
| 69 | ||
| 70 | return themes.map((theme, index) => { | |
| 71 | const shortLabel = words[index]?.slice(Math.max(0, prefixLength)).join(" ").trim(); | |
| 72 | return shortLabel || theme.label; | |
| 73 | }); | |
| 74 | } | |
| 75 | ||
| 57 | 76 | function downloadThemeFile(filename: string, contents: string): void { |
| 58 | 77 | const url = URL.createObjectURL(new Blob([contents], { type: "application/json" })); |
| 59 | 78 | const anchor = document.createElement("a"); |
| ... | ... | @@ -65,6 +84,15 @@ function downloadThemeFile(filename: string, contents: string): void { |
| 65 | 84 | setTimeout(() => URL.revokeObjectURL(url), 30_000); |
| 66 | 85 | } |
| 67 | 86 | |
| 87 | function ThemeVariantTooltip({ label, children }: { label: string; children: ReactElement }) { | |
| 88 | return ( | |
| 89 | <Tooltip> | |
| 90 | <TooltipTrigger render={children} /> | |
| 91 | <TooltipPopup>{label}</TooltipPopup> | |
| 92 | </Tooltip> | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 68 | 96 | function ThemeLibraryCard({ |
| 69 | 97 | theme, |
| 70 | 98 | isActive, |
| ... | ... | @@ -75,6 +103,7 @@ function ThemeLibraryCard({ |
| 75 | 103 | onDuplicate, |
| 76 | 104 | onDownload, |
| 77 | 105 | onRemove, |
| 106 | variantNavigation, | |
| 78 | 107 | }: { |
| 79 | 108 | theme: ThemeCardDefinition; |
| 80 | 109 | isActive: boolean; |
| ... | ... | @@ -85,10 +114,33 @@ function ThemeLibraryCard({ |
| 85 | 114 | onDuplicate?: () => void; |
| 86 | 115 | onDownload?: () => void; |
| 87 | 116 | onRemove?: () => void; |
| 117 | variantNavigation?: { | |
| 118 | collectionLabel: string; | |
| 119 | options: ReadonlyArray<{ | |
| 120 | themeIndex: number; | |
| 121 | label: string; | |
| 122 | activeModes: ReadonlyArray<ThemeMode>; | |
| 123 | preview: ThemeCardDefinition["previews"][number]; | |
| 124 | }>; | |
| 125 | onSelectAndUse: (themeIndex: number, mode: ThemeAppearance) => void; | |
| 126 | }; | |
| 88 | 127 | }) { |
| 89 | 128 | // A one-appearance theme can only take its own side of the mix, so the card |
| 90 | 129 | // tooltip promises exactly what clicking it does. |
| 91 | 130 | const cardModes = theme.previews.map((preview) => preview.mode); |
| 131 | const [radialModeOpen, setRadialModeOpen] = useState<ThemeAppearance | null>(null); | |
| 132 | const radialModeGroups = (["light", "dark"] as const).map((mode) => { | |
| 133 | const options = | |
| 134 | variantNavigation?.options.flatMap((option) => { | |
| 135 | const preview = option.preview; | |
| 136 | return preview.mode === mode ? [{ option, preview }] : []; | |
| 137 | }) ?? []; | |
| 138 | return { | |
| 139 | mode, | |
| 140 | options, | |
| 141 | selected: options.find(({ option }) => option.activeModes.includes(mode)) ?? options[0], | |
| 142 | }; | |
| 143 | }); | |
| 92 | 144 | return ( |
| 93 | 145 | // The card surface stays a plain div (buttons cannot nest inside a button |
| 94 | 146 | // role); the title button and mode circles carry the accessible actions, |
| ... | ... | @@ -105,17 +157,147 @@ function ThemeLibraryCard({ |
| 105 | 157 | onClick={onUse} |
| 106 | 158 | style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined} |
| 107 | 159 | > |
| 108 | <ThemePreviewCircles | |
| 109 | label={theme.label} | |
| 110 | activeModes={activeModes} | |
| 111 | onSelectMode={onUseMode} | |
| 112 | previews={theme.previews} | |
| 113 | /> | |
| 160 | <div className="relative"> | |
| 161 | {variantNavigation ? ( | |
| 162 | <div | |
| 163 | aria-label="Light and dark theme variants" | |
| 164 | className="relative h-20" | |
| 165 | role="group" | |
| 166 | onBlurCapture={(event) => { | |
| 167 | const nextTarget = event.relatedTarget; | |
| 168 | if ( | |
| 169 | !(nextTarget instanceof Node) || | |
| 170 | !event.currentTarget.contains(nextTarget) | |
| 171 | ) { | |
| 172 | setRadialModeOpen(null); | |
| 173 | } | |
| 174 | }} | |
| 175 | onKeyDown={(event) => { | |
| 176 | if (event.key === "Escape") setRadialModeOpen(null); | |
| 177 | }} | |
| 178 | onMouseLeave={() => setRadialModeOpen(null)} | |
| 179 | > | |
| 180 | {radialModeGroups.map(({ mode, options, selected }) => { | |
| 181 | if (!selected) return null; | |
| 182 | const rootOffsetX = mode === "light" ? -52 : 52; | |
| 183 | const isOpen = radialModeOpen === mode; | |
| 184 | const isActive = selected.option.activeModes.includes(mode); | |
| 185 | const modeLabel = mode === "light" ? "Light" : "Dark"; | |
| 186 | return ( | |
| 187 | <div className="contents" key={mode}> | |
| 188 | <ThemeVariantTooltip label={`${modeLabel}: ${selected.option.label}`}> | |
| 189 | <button | |
| 190 | aria-label={ | |
| 191 | options.length > 1 | |
| 192 | ? `Choose ${mode} variant, ${options.length} options, currently ${selected.option.label}` | |
| 193 | : `Use ${mode} variant, currently ${selected.option.label}` | |
| 194 | } | |
| 195 | aria-pressed={isActive} | |
| 196 | className="absolute left-1/2 top-2 z-20 flex size-14 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring" | |
| 197 | style={{ | |
| 198 | transform: `translateX(calc(-50% + ${rootOffsetX}px))`, | |
| 199 | }} | |
| 200 | type="button" | |
| 201 | onClick={(event) => { | |
| 202 | event.stopPropagation(); | |
| 203 | variantNavigation.onSelectAndUse(selected.option.themeIndex, mode); | |
| 204 | }} | |
| 205 | onFocus={() => setRadialModeOpen(mode)} | |
| 206 | onMouseEnter={() => setRadialModeOpen(mode)} | |
| 207 | > | |
| 208 | <ThemePreviewCircle | |
| 209 | colors={selected.preview.colors} | |
| 210 | mode={selected.preview.mode} | |
| 211 | /> | |
| 212 | {isActive ? ( | |
| 213 | <span | |
| 214 | aria-hidden | |
| 215 | className="pointer-events-none absolute inset-0 rounded-full ring-2 ring-ring" | |
| 216 | /> | |
| 217 | ) : null} | |
| 218 | {isActive ? ( | |
| 219 | <span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full border border-border/70 bg-background text-foreground shadow-sm"> | |
| 220 | {mode === "light" ? ( | |
| 221 | <SunIcon className="size-2.5" /> | |
| 222 | ) : ( | |
| 223 | <MoonIcon className="size-2.5" /> | |
| 224 | )} | |
| 225 | </span> | |
| 226 | ) : null} | |
| 227 | </button> | |
| 228 | </ThemeVariantTooltip> | |
| 229 | <span | |
| 230 | className="pointer-events-none absolute bottom-0 left-1/2 inline-flex max-w-24 -translate-x-1/2 items-center gap-1 text-[11px] font-medium text-foreground" | |
| 231 | style={{ marginLeft: rootOffsetX }} | |
| 232 | > | |
| 233 | <span className="truncate">{selected.option.label}</span> | |
| 234 | {options.length > 1 ? ( | |
| 235 | <span className="shrink-0 rounded-full bg-muted px-1 text-[9px] text-muted-foreground"> | |
| 236 | +{options.length - 1} | |
| 237 | </span> | |
| 238 | ) : null} | |
| 239 | </span> | |
| 240 | {options.length > 1 | |
| 241 | ? options.map(({ option, preview }, optionIndex) => { | |
| 242 | const progress = optionIndex / (options.length - 1) - 0.5; | |
| 243 | const childOffsetX = rootOffsetX + progress * 68; | |
| 244 | const childOffsetY = Math.abs(progress) * 10; | |
| 245 | const optionIsActive = option.activeModes.includes(mode); | |
| 246 | return ( | |
| 247 | <ThemeVariantTooltip | |
| 248 | key={option.label} | |
| 249 | label={`Use ${option.label} for ${mode} mode`} | |
| 250 | > | |
| 251 | <button | |
| 252 | aria-label={`Use ${option.label} for ${mode} mode${optionIsActive ? ", currently active" : ""}`} | |
| 253 | aria-pressed={optionIsActive} | |
| 254 | className={cn( | |
| 255 | "absolute left-1/2 top-1 z-30 flex size-7 items-center justify-center rounded-full bg-background shadow-sm outline-none transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-ring", | |
| 256 | optionIsActive ? "ring-2 ring-ring" : "ring-1 ring-border/70", | |
| 257 | )} | |
| 258 | style={{ | |
| 259 | opacity: isOpen ? 1 : 0, | |
| 260 | pointerEvents: isOpen ? "auto" : "none", | |
| 261 | transform: `translate(calc(-50% + ${isOpen ? childOffsetX : rootOffsetX}px), ${isOpen ? childOffsetY : 28}px) scale(${isOpen ? 1 : 0.55})`, | |
| 262 | transitionDelay: isOpen ? `${optionIndex * 35}ms` : "0ms", | |
| 263 | }} | |
| 264 | type="button" | |
| 265 | onClick={(event) => { | |
| 266 | event.stopPropagation(); | |
| 267 | variantNavigation.onSelectAndUse(option.themeIndex, mode); | |
| 268 | }} | |
| 269 | onFocus={() => setRadialModeOpen(mode)} | |
| 270 | onMouseEnter={() => setRadialModeOpen(mode)} | |
| 271 | > | |
| 272 | <span className="pointer-events-none scale-[0.43]"> | |
| 273 | <ThemePreviewCircle | |
| 274 | colors={preview.colors} | |
| 275 | mode={preview.mode} | |
| 276 | /> | |
| 277 | </span> | |
| 278 | </button> | |
| 279 | </ThemeVariantTooltip> | |
| 280 | ); | |
| 281 | }) | |
| 282 | : null} | |
| 283 | </div> | |
| 284 | ); | |
| 285 | })} | |
| 286 | </div> | |
| 287 | ) : ( | |
| 288 | <ThemePreviewCircles | |
| 289 | label={theme.label} | |
| 290 | activeModes={activeModes} | |
| 291 | onSelectMode={onUseMode} | |
| 292 | previews={theme.previews} | |
| 293 | /> | |
| 294 | )} | |
| 295 | </div> | |
| 114 | 296 | <div className="flex items-center gap-2 px-3 pb-3 pt-2"> |
| 115 | 297 | <div className="min-w-0 flex-1"> |
| 116 | 298 | <div className="flex items-center gap-1.5"> |
| 117 | 299 | <button |
| 118 | aria-label={`Use ${theme.label} theme${isActive ? ", currently active" : ""}`} | |
| 300 | aria-label={`Use ${variantNavigation ? `${variantNavigation.collectionLabel}, ${theme.label} variant` : `${theme.label} theme`}${isActive ? ", currently active" : ""}`} | |
| 119 | 301 | aria-pressed={isActive} |
| 120 | 302 | className="min-w-0 cursor-pointer truncate rounded-sm text-left text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card" |
| 121 | 303 | type="button" |
| ... | ... | @@ -124,7 +306,7 @@ function ThemeLibraryCard({ |
| 124 | 306 | onUse(); |
| 125 | 307 | }} |
| 126 | 308 | > |
| 127 | {theme.label} | |
| 309 | {variantNavigation?.collectionLabel ?? theme.label} | |
| 128 | 310 | </button> |
| 129 | 311 | </div> |
| 130 | 312 | </div> |
| ... | ... | @@ -195,7 +377,11 @@ function ThemeLibraryCard({ |
| 195 | 377 | <TooltipTrigger |
| 196 | 378 | render={ |
| 197 | 379 | <Button |
| 198 | aria-label={`Remove ${theme.label}`} | |
| 380 | aria-label={ | |
| 381 | variantNavigation | |
| 382 | ? `Remove themes from ${variantNavigation.collectionLabel}` | |
| 383 | : `Remove ${theme.label}` | |
| 384 | } | |
| 199 | 385 | size="icon-xs" |
| 200 | 386 | variant="ghost" |
| 201 | 387 | className="text-muted-foreground hover:text-destructive" |
| ... | ... | @@ -208,7 +394,9 @@ function ThemeLibraryCard({ |
| 208 | 394 | </Button> |
| 209 | 395 | } |
| 210 | 396 | /> |
| 211 | <TooltipPopup>Remove theme</TooltipPopup> | |
| 397 | <TooltipPopup> | |
| 398 | {variantNavigation ? "Remove themes" : "Remove theme"} | |
| 399 | </TooltipPopup> | |
| 212 | 400 | </Tooltip> |
| 213 | 401 | ) : null} |
| 214 | 402 | </div> |
| ... | ... | @@ -218,12 +406,97 @@ function ThemeLibraryCard({ |
| 218 | 406 | } |
| 219 | 407 | /> |
| 220 | 408 | <TooltipPopup> |
| 221 | {cardModes.length > 1 ? "Use for both light and dark" : `Use for ${cardModes[0]} mode only`} | |
| 409 | {variantNavigation | |
| 410 | ? "Use the first variants for light and dark" | |
| 411 | : cardModes.length > 1 | |
| 412 | ? "Use for both light and dark" | |
| 413 | : `Use for ${cardModes[0]} mode only`} | |
| 222 | 414 | </TooltipPopup> |
| 223 | 415 | </Tooltip> |
| 224 | 416 | ); |
| 225 | 417 | } |
| 226 | 418 | |
| 419 | function CustomThemeCollectionCard({ | |
| 420 | themes, | |
| 421 | activeModesFor, | |
| 422 | onUse, | |
| 423 | onUseMode, | |
| 424 | onDuplicate, | |
| 425 | onEdit, | |
| 426 | onDownload, | |
| 427 | onRemove, | |
| 428 | }: { | |
| 429 | themes: ReadonlyArray<ThemeDefinition>; | |
| 430 | activeModesFor: (themeId: string) => ReadonlyArray<ThemeMode>; | |
| 431 | onUse: (theme: ThemeDefinition) => void; | |
| 432 | onUseMode: (theme: ThemeDefinition, mode: ThemeMode) => void; | |
| 433 | onDuplicate: (theme: ThemeDefinition) => void; | |
| 434 | onEdit: (theme: ThemeDefinition) => void; | |
| 435 | onDownload: (theme: ThemeDefinition) => void; | |
| 436 | onRemove: (theme: ThemeDefinition) => void; | |
| 437 | }) { | |
| 438 | const [variantIndex, setVariantIndex] = useState(() => { | |
| 439 | const activeIndex = themes.findIndex((theme) => activeModesFor(theme.id).length > 0); | |
| 440 | return activeIndex < 0 ? 0 : activeIndex; | |
| 441 | }); | |
| 442 | const safeIndex = Math.min(variantIndex, themes.length - 1); | |
| 443 | const theme = themes[safeIndex]; | |
| 444 | ||
| 445 | useEffect(() => { | |
| 446 | if (variantIndex !== safeIndex) setVariantIndex(safeIndex); | |
| 447 | }, [safeIndex, variantIndex]); | |
| 448 | ||
| 449 | if (!theme) return null; | |
| 450 | const collectionLabel = theme.collection?.label ?? theme.label; | |
| 451 | const variantLabels = collectionVariantLabels(themes); | |
| 452 | const defaultLightTheme = themes.find((candidate) => getThemeModes(candidate).includes("light")); | |
| 453 | const defaultDarkTheme = themes.find((candidate) => getThemeModes(candidate).includes("dark")); | |
| 454 | const selectCollectionDefaults = () => { | |
| 455 | if (themes.length === 1) { | |
| 456 | onUse(theme); | |
| 457 | return; | |
| 458 | } | |
| 459 | if (defaultLightTheme) onUseMode(defaultLightTheme, "light"); | |
| 460 | if (defaultDarkTheme) onUseMode(defaultDarkTheme, "dark"); | |
| 461 | setVariantIndex(0); | |
| 462 | }; | |
| 463 | ||
| 464 | return ( | |
| 465 | <ThemeLibraryCard | |
| 466 | activeModes={activeModesFor(theme.id)} | |
| 467 | isActive={false} | |
| 468 | onDownload={() => onDownload(theme)} | |
| 469 | onDuplicate={() => onDuplicate(theme)} | |
| 470 | onEdit={() => onEdit(theme)} | |
| 471 | onRemove={() => onRemove(theme)} | |
| 472 | onUse={selectCollectionDefaults} | |
| 473 | onUseMode={(mode) => onUseMode(theme, mode)} | |
| 474 | theme={getThemeCardDefinition(theme)} | |
| 475 | {...(themes.length > 1 | |
| 476 | ? { | |
| 477 | variantNavigation: { | |
| 478 | collectionLabel, | |
| 479 | options: themes.flatMap((variant, themeIndex) => | |
| 480 | getThemeCardDefinition(variant).previews.map((preview) => ({ | |
| 481 | themeIndex, | |
| 482 | label: variantLabels[themeIndex] ?? variant.label, | |
| 483 | activeModes: activeModesFor(variant.id), | |
| 484 | preview, | |
| 485 | })), | |
| 486 | ), | |
| 487 | onSelectAndUse: (themeIndex, mode) => { | |
| 488 | const selectedTheme = themes[themeIndex]; | |
| 489 | if (!selectedTheme) return; | |
| 490 | setVariantIndex(themeIndex); | |
| 491 | onUseMode(selectedTheme, mode); | |
| 492 | }, | |
| 493 | }, | |
| 494 | } | |
| 495 | : {})} | |
| 496 | /> | |
| 497 | ); | |
| 498 | } | |
| 499 | ||
| 227 | 500 | export function ThemeLibrary({ |
| 228 | 501 | theme, |
| 229 | 502 | setTheme, |
| ... | ... | @@ -250,14 +523,20 @@ export function ThemeLibrary({ |
| 250 | 523 | setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean; |
| 251 | 524 | }) { |
| 252 | 525 | const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); |
| 253 | const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null); | |
| 254 | // Keep the last removal target so the dialog title stays populated while the | |
| 255 | // close animation plays after confirming. | |
| 256 | const lastThemeToRemoveRef = useRef<ThemeDefinition | null>(null); | |
| 257 | useEffect(() => { | |
| 258 | if (themeToRemove) lastThemeToRemoveRef.current = themeToRemove; | |
| 259 | }, [themeToRemove]); | |
| 260 | const removeDialogTheme = themeToRemove ?? lastThemeToRemoveRef.current; | |
| 526 | const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ | |
| 527 | theme: ThemeDefinition; | |
| 528 | collectionThemes: ReadonlyArray<ThemeDefinition>; | |
| 529 | } | null>(null); | |
| 530 | // Keep the target after closing so the dialog text remains populated during | |
| 531 | // its exit animation. The next trash action replaces it before reopening. | |
| 532 | const [isThemeRemovalOpen, setIsThemeRemovalOpen] = useState(false); | |
| 533 | const [themeIdsToRemove, setThemeIdsToRemove] = useState<ReadonlyArray<string>>([]); | |
| 534 | const themeIdsToRemoveSet = new Set(themeIdsToRemove); | |
| 535 | const removeDialogTheme = themeRemovalTarget?.theme; | |
| 536 | const removeDialogCollectionThemes = themeRemovalTarget?.collectionThemes ?? []; | |
| 537 | const canRemoveCollection = removeDialogCollectionThemes.length > 1; | |
| 538 | const removeDialogCollectionLabel = | |
| 539 | removeDialogTheme?.collection?.label ?? removeDialogTheme?.label; | |
| 261 | 540 | |
| 262 | 541 | const notifyThemeSaveFailure = useCallback(() => { |
| 263 | 542 | toastManager.add( |
| ... | ... | @@ -288,37 +567,43 @@ export function ThemeLibrary({ |
| 288 | 567 | [notifyThemeSaveFailure, setTheme], |
| 289 | 568 | ); |
| 290 | 569 | |
| 291 | const handleRemoveTheme = useCallback((customTheme: ThemeDefinition) => { | |
| 292 | setThemeToRemove(customTheme); | |
| 293 | }, []); | |
| 570 | const handleRemoveTheme = useCallback( | |
| 571 | (customTheme: ThemeDefinition, collectionThemes: ReadonlyArray<ThemeDefinition>) => { | |
| 572 | setThemeRemovalTarget({ theme: customTheme, collectionThemes }); | |
| 573 | setThemeIdsToRemove(collectionThemes.length > 1 ? [] : [customTheme.id]); | |
| 574 | setIsThemeRemovalOpen(true); | |
| 575 | }, | |
| 576 | [], | |
| 577 | ); | |
| 294 | 578 | |
| 295 | 579 | const handleConfirmRemoveTheme = useCallback(() => { |
| 296 | if (!themeToRemove) return; | |
| 297 | const removesBase = getThemeDefinition(theme)?.id === themeToRemove.id; | |
| 298 | // Keep the theme installed if we cannot move the selection off it; the | |
| 299 | // dialog stays open so the user can retry or cancel. | |
| 580 | if (!themeRemovalTarget) return; | |
| 581 | const removedIds = new Set(themeIdsToRemove); | |
| 582 | if (removedIds.size === 0) return; | |
| 583 | const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? ""); | |
| 584 | // Keep the themes installed if we cannot move the selection off one of | |
| 585 | // them; the dialog stays open so the user can retry or cancel. | |
| 300 | 586 | if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) { |
| 301 | 587 | return; |
| 302 | 588 | } |
| 303 | 589 | for (const appearance of ["light", "dark"] as const) { |
| 304 | 590 | const half = themeHalves?.[appearance]; |
| 305 | 591 | if (half === undefined) continue; |
| 306 | // Writing a base preference clears the whole mix, so halves that name a | |
| 307 | // surviving theme are written back; halves on the removed theme fall | |
| 308 | // back to the base. | |
| 309 | const next = half === themeToRemove.id ? null : removesBase ? half : undefined; | |
| 592 | // Writing a base preference clears the whole mix, so halves that name | |
| 593 | // a surviving theme are written back; removed halves fall back to base. | |
| 594 | const next = half && removedIds.has(half) ? null : removesBase ? half : undefined; | |
| 310 | 595 | if (next !== undefined && !setThemeHalf(appearance, next)) { |
| 311 | 596 | notifyThemeRemovalFailure(); |
| 312 | 597 | return; |
| 313 | 598 | } |
| 314 | 599 | } |
| 315 | 600 | try { |
| 316 | removeCustomTheme(themeToRemove.id); | |
| 601 | removeCustomThemes([...removedIds]); | |
| 317 | 602 | } catch { |
| 318 | 603 | notifyThemeRemovalFailure(); |
| 319 | 604 | return; |
| 320 | 605 | } |
| 321 | setThemeToRemove(null); | |
| 606 | setIsThemeRemovalOpen(false); | |
| 322 | 607 | }, [ |
| 323 | 608 | appearanceMode, |
| 324 | 609 | notifyThemeRemovalFailure, |
| ... | ... | @@ -326,7 +611,8 @@ export function ThemeLibrary({ |
| 326 | 611 | setThemeHalf, |
| 327 | 612 | theme, |
| 328 | 613 | themeHalves, |
| 329 | themeToRemove, | |
| 614 | themeIdsToRemove, | |
| 615 | themeRemovalTarget, | |
| 330 | 616 | ]); |
| 331 | 617 | |
| 332 | 618 | // ----- Automatic-mode mixing ------------------------------------------- |
| ... | ... | @@ -460,6 +746,20 @@ export function ThemeLibrary({ |
| 460 | 746 | </div> |
| 461 | 747 | ); |
| 462 | 748 | |
| 749 | const customThemeCollections = [ | |
| 750 | ...customThemes | |
| 751 | .reduce((groups, customTheme) => { | |
| 752 | const groupId = customTheme.collection | |
| 753 | ? `collection:${customTheme.collection.id}` | |
| 754 | : `theme:${customTheme.id}`; | |
| 755 | const group = groups.get(groupId); | |
| 756 | if (group) group.push(customTheme); | |
| 757 | else groups.set(groupId, [customTheme]); | |
| 758 | return groups; | |
| 759 | }, new Map<string, ThemeDefinition[]>()) | |
| 760 | .entries(), | |
| 761 | ]; | |
| 762 | ||
| 463 | 763 | const renderPairGrid = () => ( |
| 464 | 764 | // One shared provider so every tooltip in the grid hands off instantly to |
| 465 | 765 | // the next hovered trigger instead of stacking on top of it. The card |
| ... | ... | @@ -509,43 +809,39 @@ export function ThemeLibrary({ |
| 509 | 809 | /> |
| 510 | 810 | ); |
| 511 | 811 | })} |
| 512 | {customThemes.map((customTheme) => { | |
| 513 | const card = getThemeCardDefinition(customTheme); | |
| 514 | return ( | |
| 515 | <ThemeLibraryCard | |
| 516 | activeModes={pickedModesFor(customTheme.id)} | |
| 517 | isActive={false} | |
| 518 | key={customTheme.id} | |
| 519 | onDuplicate={() => | |
| 520 | openThemeEditor({ | |
| 521 | editingThemeId: null, | |
| 522 | seedThemeId: customTheme.id, | |
| 523 | seedName: `${customTheme.label} copy`, | |
| 524 | initialAppearance, | |
| 525 | }) | |
| 526 | } | |
| 527 | onEdit={() => | |
| 528 | openThemeEditor({ | |
| 529 | editingThemeId: customTheme.id, | |
| 530 | seedThemeId: null, | |
| 531 | seedName: null, | |
| 532 | initialAppearance, | |
| 533 | }) | |
| 534 | } | |
| 535 | onDownload={() => | |
| 536 | downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme)) | |
| 537 | } | |
| 538 | onRemove={() => handleRemoveTheme(customTheme)} | |
| 539 | onUse={() => { | |
| 540 | const modes = getThemeModes(customTheme); | |
| 541 | if (modes.length === 1) assignHalf(modes[0]!, customTheme.id); | |
| 542 | else persistTheme(customTheme.id); | |
| 543 | }} | |
| 544 | onUseMode={handlePairPick(customTheme.id)} | |
| 545 | theme={card} | |
| 546 | /> | |
| 547 | ); | |
| 548 | })} | |
| 812 | {customThemeCollections.map(([collectionId, themes]) => ( | |
| 813 | <CustomThemeCollectionCard | |
| 814 | activeModesFor={pickedModesFor} | |
| 815 | key={collectionId} | |
| 816 | onDownload={(customTheme) => | |
| 817 | downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme)) | |
| 818 | } | |
| 819 | onDuplicate={(customTheme) => | |
| 820 | openThemeEditor({ | |
| 821 | editingThemeId: null, | |
| 822 | seedThemeId: customTheme.id, | |
| 823 | seedName: `${customTheme.label} copy`, | |
| 824 | initialAppearance, | |
| 825 | }) | |
| 826 | } | |
| 827 | onEdit={(customTheme) => | |
| 828 | openThemeEditor({ | |
| 829 | editingThemeId: customTheme.id, | |
| 830 | seedThemeId: null, | |
| 831 | seedName: null, | |
| 832 | initialAppearance, | |
| 833 | }) | |
| 834 | } | |
| 835 | onRemove={(customTheme) => handleRemoveTheme(customTheme, themes)} | |
| 836 | onUse={(customTheme) => { | |
| 837 | const modes = getThemeModes(customTheme); | |
| 838 | if (modes.length === 1) assignHalf(modes[0]!, customTheme.id); | |
| 839 | else persistTheme(customTheme.id); | |
| 840 | }} | |
| 841 | onUseMode={(customTheme, mode) => handlePairPick(customTheme.id)(mode)} | |
| 842 | themes={themes} | |
| 843 | /> | |
| 844 | ))} | |
| 549 | 845 | </div> |
| 550 | 846 | </TooltipProvider> |
| 551 | 847 | ); |
| ... | ... | @@ -561,7 +857,7 @@ export function ThemeLibrary({ |
| 561 | 857 | {renderModeTiles()} |
| 562 | 858 | <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4"> |
| 563 | 859 | <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3> |
| 564 | <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 860 | <div className="flex flex-wrap items-center justify-end gap-2"> | |
| 565 | 861 | <Button |
| 566 | 862 | size="xs" |
| 567 | 863 | variant="outline" |
| ... | ... | @@ -586,19 +882,9 @@ export function ThemeLibrary({ |
| 586 | 882 | {renderPairGrid()} |
| 587 | 883 | <ThemeImportDialog |
| 588 | 884 | onImportedMany={(importedThemes, { updated }) => { |
| 589 | // An updated theme that is showing (as the base or either half) | |
| 590 | // needs its palette re-applied. | |
| 591 | if ( | |
| 592 | updated && | |
| 593 | importedThemes.some( | |
| 594 | (imported) => | |
| 595 | getThemeDefinition(theme)?.id === imported.id || | |
| 596 | themeHalves?.light === imported.id || | |
| 597 | themeHalves?.dark === imported.id, | |
| 598 | ) | |
| 599 | ) { | |
| 600 | refreshTheme(); | |
| 601 | } | |
| 885 | // Re-apply after collection updates. The update may remove the | |
| 886 | // selected variant, in which case the theme hook falls back safely. | |
| 887 | if (updated) refreshTheme(); | |
| 602 | 888 | const verb = updated ? "updated" : "added"; |
| 603 | 889 | toastManager.add( |
| 604 | 890 | stackedThreadToast({ |
| ... | ... | @@ -639,23 +925,80 @@ export function ThemeLibrary({ |
| 639 | 925 | onOpenChange={onImportOpenChange} |
| 640 | 926 | open={isImportOpen} |
| 641 | 927 | /> |
| 642 | <AlertDialog | |
| 643 | open={themeToRemove !== null} | |
| 644 | onOpenChange={(open) => { | |
| 645 | if (!open) setThemeToRemove(null); | |
| 646 | }} | |
| 647 | > | |
| 928 | <AlertDialog open={isThemeRemovalOpen} onOpenChange={setIsThemeRemovalOpen}> | |
| 648 | 929 | <AlertDialogPopup> |
| 649 | 930 | <AlertDialogHeader> |
| 650 | <AlertDialogTitle>Remove “{removeDialogTheme?.label}”?</AlertDialogTitle> | |
| 931 | <AlertDialogTitle> | |
| 932 | {canRemoveCollection | |
| 933 | ? `Remove themes from “${removeDialogCollectionLabel}”?` | |
| 934 | : `Remove “${removeDialogTheme?.label}”?`} | |
| 935 | </AlertDialogTitle> | |
| 651 | 936 | <AlertDialogDescription> |
| 652 | You can bring it back anytime by importing its JSON file. | |
| 937 | {canRemoveCollection | |
| 938 | ? "Select the variants you want to remove. You can restore them by importing the extension again." | |
| 939 | : "You can bring it back anytime by importing its JSON file."} | |
| 653 | 940 | </AlertDialogDescription> |
| 654 | 941 | </AlertDialogHeader> |
| 942 | {canRemoveCollection ? ( | |
| 943 | <div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto px-6 pb-6 sm:grid-cols-2"> | |
| 944 | {removeDialogCollectionThemes.map((customTheme) => { | |
| 945 | const checked = themeIdsToRemoveSet.has(customTheme.id); | |
| 946 | const card = getThemeCardDefinition(customTheme); | |
| 947 | const checkboxId = `remove-theme-${customTheme.id}`; | |
| 948 | return ( | |
| 949 | <label | |
| 950 | className="group relative flex min-h-28 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-border/70 bg-muted/25 p-3 has-checked:border-ring has-checked:bg-accent/20 hover:bg-muted/40" | |
| 951 | htmlFor={checkboxId} | |
| 952 | key={customTheme.id} | |
| 953 | > | |
| 954 | <span className="absolute right-2 top-2 inline-grid size-5 grid-cols-1 sm:size-4"> | |
| 955 | <input | |
| 956 | checked={checked} | |
| 957 | className="col-start-1 row-start-1 size-full appearance-none rounded-sm border border-input bg-background outline-none checked:border-primary checked:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring dark:not-checked:bg-input/32 forced-colors:appearance-auto" | |
| 958 | id={checkboxId} | |
| 959 | name="themes-to-remove" | |
| 960 | type="checkbox" | |
| 961 | onChange={(event) => { | |
| 962 | const shouldRemove = event.currentTarget.checked; | |
| 963 | setThemeIdsToRemove((current) => | |
| 964 | shouldRemove | |
| 965 | ? [...current, customTheme.id] | |
| 966 | : current.filter((themeId) => themeId !== customTheme.id), | |
| 967 | ); | |
| 968 | }} | |
| 969 | /> | |
| 970 | <CheckIcon className="pointer-events-none col-start-1 row-start-1 size-3.5 shrink-0 self-center justify-self-center stroke-primary-foreground opacity-0 group-has-checked:opacity-100 sm:size-3" /> | |
| 971 | </span> | |
| 972 | <span className="flex min-h-12 items-center justify-center gap-1"> | |
| 973 | {card.previews.map((preview) => ( | |
| 974 | <span | |
| 975 | className="flex size-11 shrink-0 items-center justify-center" | |
| 976 | key={preview.mode} | |
| 977 | > | |
| 978 | <span className="flex scale-75"> | |
| 979 | <ThemePreviewCircle colors={preview.colors} mode={preview.mode} /> | |
| 980 | </span> | |
| 981 | </span> | |
| 982 | ))} | |
| 983 | </span> | |
| 984 | <p className="max-w-full truncate text-center text-base font-medium text-foreground sm:text-sm"> | |
| 985 | {customTheme.label} | |
| 986 | </p> | |
| 987 | </label> | |
| 988 | ); | |
| 989 | })} | |
| 990 | </div> | |
| 991 | ) : null} | |
| 655 | 992 | <AlertDialogFooter> |
| 656 | 993 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 657 | <Button variant="destructive" onClick={handleConfirmRemoveTheme}> | |
| 658 | Remove theme | |
| 994 | <Button | |
| 995 | disabled={themeIdsToRemove.length === 0} | |
| 996 | variant="destructive" | |
| 997 | onClick={handleConfirmRemoveTheme} | |
| 998 | > | |
| 999 | {canRemoveCollection | |
| 1000 | ? `Remove selected${themeIdsToRemove.length > 0 ? ` (${themeIdsToRemove.length})` : ""}` | |
| 1001 | : "Remove theme"} | |
| 659 | 1002 | </Button> |
| 660 | 1003 | </AlertDialogFooter> |
| 661 | 1004 | </AlertDialogPopup> |
apps/web/src/openVsxThemes.test.ts created+526| ... | ... | @@ -0,0 +1,526 @@ |
| 1 | import { sha256 } from "@noble/hashes/sha2"; | |
| 2 | import JSZip from "jszip"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { | |
| 6 | importOpenVsxThemeExtension, | |
| 7 | searchOpenVsxThemes, | |
| 8 | type OpenVsxThemeExtension, | |
| 9 | } from "./openVsxThemes"; | |
| 10 | import { getThemeColorsForMode, themeColorToHex } from "./themePalette"; | |
| 11 | ||
| 12 | const ASSET_ROOT = "https://open-vsx.org/api/demo/theme/1.0.0/file"; | |
| 13 | ||
| 14 | function extensionDetail(overrides: Record<string, unknown> = {}) { | |
| 15 | return { | |
| 16 | namespace: "demo", | |
| 17 | name: "theme", | |
| 18 | displayName: "Demo Theme", | |
| 19 | description: "A nice theme", | |
| 20 | version: "1.0.0", | |
| 21 | downloadCount: 123456, | |
| 22 | license: "MIT", | |
| 23 | repository: "https://github.com/demo/theme", | |
| 24 | files: { | |
| 25 | icon: `${ASSET_ROOT}/icon.png`, | |
| 26 | manifest: `${ASSET_ROOT}/package.json`, | |
| 27 | sha256: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`, | |
| 28 | download: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`, | |
| 29 | }, | |
| 30 | ...overrides, | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | afterEach(() => { | |
| 35 | vi.useRealTimers(); | |
| 36 | vi.unstubAllGlobals(); | |
| 37 | }); | |
| 38 | ||
| 39 | describe("Open VSX themes", () => { | |
| 40 | it("searches theme extensions and keeps only supported open-source licenses", async () => { | |
| 41 | const fetchMock = vi.fn(async (input: RequestInfo | URL) => { | |
| 42 | const url = String(input); | |
| 43 | if (url.includes("/-/search?")) { | |
| 44 | return new Response( | |
| 45 | JSON.stringify({ | |
| 46 | extensions: [ | |
| 47 | { namespace: "demo", name: "theme" }, | |
| 48 | { namespace: "icons", name: "theme" }, | |
| 49 | { namespace: "oversized", name: "theme" }, | |
| 50 | { namespace: "unlicensed", name: "theme" }, | |
| 51 | { namespace: "unavailable", name: "theme" }, | |
| 52 | { namespace: "closed", name: "theme" }, | |
| 53 | { namespace: "huge", name: "theme" }, | |
| 54 | ], | |
| 55 | }), | |
| 56 | { status: 200 }, | |
| 57 | ); | |
| 58 | } | |
| 59 | if (url.endsWith("/icons/theme")) { | |
| 60 | return new Response( | |
| 61 | JSON.stringify( | |
| 62 | extensionDetail({ | |
| 63 | namespace: "icons", | |
| 64 | files: { | |
| 65 | ...extensionDetail().files, | |
| 66 | manifest: `${ASSET_ROOT.replace("demo/theme", "icons/theme")}/package.json`, | |
| 67 | }, | |
| 68 | }), | |
| 69 | ), | |
| 70 | { status: 200 }, | |
| 71 | ); | |
| 72 | } | |
| 73 | if (url.endsWith("/oversized/theme")) { | |
| 74 | return new Response( | |
| 75 | JSON.stringify( | |
| 76 | extensionDetail({ | |
| 77 | namespace: "oversized", | |
| 78 | files: { | |
| 79 | ...extensionDetail().files, | |
| 80 | download: `${ASSET_ROOT.replace("demo/theme", "oversized/theme")}/oversized.vsix`, | |
| 81 | }, | |
| 82 | }), | |
| 83 | ), | |
| 84 | { status: 200 }, | |
| 85 | ); | |
| 86 | } | |
| 87 | if (url.endsWith("/unlicensed/theme")) { | |
| 88 | return new Response( | |
| 89 | JSON.stringify( | |
| 90 | extensionDetail({ | |
| 91 | namespace: "unlicensed", | |
| 92 | files: { | |
| 93 | ...extensionDetail().files, | |
| 94 | manifest: `${ASSET_ROOT.replace("demo/theme", "unlicensed/theme")}/package.json`, | |
| 95 | }, | |
| 96 | }), | |
| 97 | ), | |
| 98 | { status: 200 }, | |
| 99 | ); | |
| 100 | } | |
| 101 | if (url.endsWith("/unavailable/theme")) { | |
| 102 | return new Response( | |
| 103 | JSON.stringify( | |
| 104 | extensionDetail({ | |
| 105 | namespace: "unavailable", | |
| 106 | files: { | |
| 107 | ...extensionDetail().files, | |
| 108 | download: `${ASSET_ROOT.replace("demo/theme", "unavailable/theme")}/unavailable.vsix`, | |
| 109 | manifest: `${ASSET_ROOT.replace("demo/theme", "unavailable/theme")}/package.json`, | |
| 110 | }, | |
| 111 | }), | |
| 112 | ), | |
| 113 | { status: 200 }, | |
| 114 | ); | |
| 115 | } | |
| 116 | if (url.endsWith("/closed/theme")) { | |
| 117 | return new Response( | |
| 118 | JSON.stringify(extensionDetail({ namespace: "closed", license: "All Rights Reserved" })), | |
| 119 | { status: 200 }, | |
| 120 | ); | |
| 121 | } | |
| 122 | if (url.endsWith("/huge/theme")) { | |
| 123 | return new Response(JSON.stringify({ padding: "x".repeat(256 * 1024) }), { status: 200 }); | |
| 124 | } | |
| 125 | if (url.endsWith("/oversized/theme/1.0.0/file/oversized.vsix")) { | |
| 126 | return new Response(null, { | |
| 127 | headers: { "content-length": String(20 * 1024 * 1024 + 1) }, | |
| 128 | status: 200, | |
| 129 | }); | |
| 130 | } | |
| 131 | if (url.endsWith("/unavailable/theme/1.0.0/file/unavailable.vsix")) { | |
| 132 | return new Response(null, { status: 404 }); | |
| 133 | } | |
| 134 | if (url.endsWith(".vsix")) { | |
| 135 | return new Response(null, { headers: { "content-length": "1024" }, status: 200 }); | |
| 136 | } | |
| 137 | if (url.endsWith("/icons/theme/1.0.0/file/package.json")) { | |
| 138 | return new Response(JSON.stringify({ contributes: { iconThemes: [{}] } }), { status: 200 }); | |
| 139 | } | |
| 140 | if (url.endsWith("/unlicensed/theme/1.0.0/file/package.json")) { | |
| 141 | return new Response( | |
| 142 | JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }), | |
| 143 | { status: 200 }, | |
| 144 | ); | |
| 145 | } | |
| 146 | if (url.endsWith("/unavailable/theme/1.0.0/file/package.json")) { | |
| 147 | return new Response( | |
| 148 | JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }), | |
| 149 | { status: 200 }, | |
| 150 | ); | |
| 151 | } | |
| 152 | if (url.endsWith("/demo/theme/1.0.0/file/package.json")) { | |
| 153 | return new Response( | |
| 154 | JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }), | |
| 155 | { status: 200 }, | |
| 156 | ); | |
| 157 | } | |
| 158 | return new Response(JSON.stringify(extensionDetail()), { status: 200 }); | |
| 159 | }); | |
| 160 | vi.stubGlobal("fetch", fetchMock); | |
| 161 | ||
| 162 | const results = await searchOpenVsxThemes(" dracula "); | |
| 163 | ||
| 164 | expect(results).toEqual([ | |
| 165 | expect.objectContaining({ | |
| 166 | id: "demo.theme", | |
| 167 | name: "Demo Theme", | |
| 168 | publisher: "demo", | |
| 169 | downloadCount: 123456, | |
| 170 | iconUrl: `${ASSET_ROOT}/icon.png`, | |
| 171 | sourceUrl: "https://github.com/demo/theme", | |
| 172 | license: "MIT", | |
| 173 | }), | |
| 174 | ]); | |
| 175 | const searchUrl = new URL(String(fetchMock.mock.calls[0]![0])); | |
| 176 | expect(searchUrl.searchParams.get("query")).toBe("dracula"); | |
| 177 | expect(searchUrl.searchParams.get("category")).toBe("Themes"); | |
| 178 | expect(searchUrl.searchParams.get("sortBy")).toBe("downloadCount"); | |
| 179 | expect(searchUrl.searchParams.get("sortOrder")).toBe("desc"); | |
| 180 | expect(searchUrl.searchParams.get("size")).toBe("16"); | |
| 181 | }); | |
| 182 | ||
| 183 | it("supports sorting theme searches", async () => { | |
| 184 | const fetchMock = vi.fn( | |
| 185 | async (..._args: unknown[]) => | |
| 186 | new Response(JSON.stringify({ extensions: [] }), { status: 200 }), | |
| 187 | ); | |
| 188 | vi.stubGlobal("fetch", fetchMock); | |
| 189 | ||
| 190 | await searchOpenVsxThemes("nord", { sortBy: "rating" }); | |
| 191 | ||
| 192 | const searchUrl = new URL(String(fetchMock.mock.calls[0]![0])); | |
| 193 | expect(searchUrl.searchParams.get("sortBy")).toBe("rating"); | |
| 194 | expect(searchUrl.searchParams.get("sortOrder")).toBe("desc"); | |
| 195 | }); | |
| 196 | ||
| 197 | it("rejects malformed search responses", async () => { | |
| 198 | vi.stubGlobal( | |
| 199 | "fetch", | |
| 200 | vi.fn(async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 200 })), | |
| 201 | ); | |
| 202 | ||
| 203 | await expect(searchOpenVsxThemes("nord")).rejects.toThrow( | |
| 204 | "Open VSX returned an unreadable search response.", | |
| 205 | ); | |
| 206 | }); | |
| 207 | ||
| 208 | it("reports an unavailable search when every detail request fails", async () => { | |
| 209 | vi.stubGlobal( | |
| 210 | "fetch", | |
| 211 | vi.fn(async (input: RequestInfo | URL) => { | |
| 212 | if (String(input).includes("/-/search?")) { | |
| 213 | return new Response( | |
| 214 | JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }), | |
| 215 | { status: 200 }, | |
| 216 | ); | |
| 217 | } | |
| 218 | return new Response(null, { status: 429 }); | |
| 219 | }), | |
| 220 | ); | |
| 221 | ||
| 222 | await expect(searchOpenVsxThemes("dracula")).rejects.toThrow( | |
| 223 | "Open VSX theme details are unavailable", | |
| 224 | ); | |
| 225 | }); | |
| 226 | ||
| 227 | it("reports an unavailable search when every detail response is malformed", async () => { | |
| 228 | vi.stubGlobal( | |
| 229 | "fetch", | |
| 230 | vi.fn(async (input: RequestInfo | URL) => { | |
| 231 | if (String(input).includes("/-/search?")) { | |
| 232 | return new Response( | |
| 233 | JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }), | |
| 234 | { status: 200 }, | |
| 235 | ); | |
| 236 | } | |
| 237 | return new Response("{}", { status: 200 }); | |
| 238 | }), | |
| 239 | ); | |
| 240 | ||
| 241 | await expect(searchOpenVsxThemes("dracula")).rejects.toThrow( | |
| 242 | "Open VSX theme details are unavailable", | |
| 243 | ); | |
| 244 | }); | |
| 245 | ||
| 246 | it("returns no results when every theme package is unavailable", async () => { | |
| 247 | vi.stubGlobal( | |
| 248 | "fetch", | |
| 249 | vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 250 | const url = String(input); | |
| 251 | if (url.includes("/-/search?")) { | |
| 252 | return new Response( | |
| 253 | JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }), | |
| 254 | { status: 200 }, | |
| 255 | ); | |
| 256 | } | |
| 257 | if (url.endsWith("/demo/theme")) { | |
| 258 | return new Response(JSON.stringify(extensionDetail()), { status: 200 }); | |
| 259 | } | |
| 260 | if (init?.method === "HEAD") return new Response(null, { status: 404 }); | |
| 261 | return new Response( | |
| 262 | JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }), | |
| 263 | { status: 200 }, | |
| 264 | ); | |
| 265 | }), | |
| 266 | ); | |
| 267 | ||
| 268 | await expect(searchOpenVsxThemes("dracula")).resolves.toEqual([]); | |
| 269 | }); | |
| 270 | ||
| 271 | it("times out a stalled search request", async () => { | |
| 272 | vi.useFakeTimers(); | |
| 273 | vi.stubGlobal( | |
| 274 | "fetch", | |
| 275 | vi.fn( | |
| 276 | (_input: RequestInfo | URL, init?: RequestInit) => | |
| 277 | new Promise<Response>((_resolve, reject) => { | |
| 278 | init?.signal?.addEventListener("abort", () => { | |
| 279 | reject(new DOMException("The operation was aborted.", "AbortError")); | |
| 280 | }); | |
| 281 | }), | |
| 282 | ), | |
| 283 | ); | |
| 284 | ||
| 285 | const search = expect(searchOpenVsxThemes("dracula")).rejects.toThrow( | |
| 286 | "Open VSX took too long to respond", | |
| 287 | ); | |
| 288 | await vi.advanceTimersByTimeAsync(10_000); | |
| 289 | ||
| 290 | await search; | |
| 291 | }); | |
| 292 | ||
| 293 | it("downloads a verified VSIX, reads JSONC includes, and pairs contributed variants", async () => { | |
| 294 | const zip = new JSZip(); | |
| 295 | zip.file("extension/.gitkeep", ""); | |
| 296 | zip.file( | |
| 297 | "extension/themes/base.jsonc", | |
| 298 | `{ | |
| 299 | // inherited workbench colors | |
| 300 | "colors": { | |
| 301 | "editor.foreground": "#eeeeee", | |
| 302 | "focusBorder": "#8b5cf6", | |
| 303 | }, | |
| 304 | }`, | |
| 305 | ); | |
| 306 | zip.file( | |
| 307 | "extension/themes/demo-dark.json", | |
| 308 | `{ | |
| 309 | "include": "./base.jsonc", | |
| 310 | "colors": { "editor.background": "#111111" } | |
| 311 | }`, | |
| 312 | ); | |
| 313 | zip.file( | |
| 314 | "extension/themes/demo-light.json", | |
| 315 | `{ | |
| 316 | "colors": { | |
| 317 | "editor.background": "#fafafa", | |
| 318 | "editor.foreground": "#222222", | |
| 319 | "focusBorder": "#8b5cf6" | |
| 320 | } | |
| 321 | }`, | |
| 322 | ); | |
| 323 | zip.file( | |
| 324 | "extension/themes/demo.json", | |
| 325 | `{ | |
| 326 | "colors": { | |
| 327 | "editor.background": "#181818", | |
| 328 | "editor.foreground": "#eeeeee" | |
| 329 | } | |
| 330 | }`, | |
| 331 | ); | |
| 332 | const packagedManifest = { | |
| 333 | publisher: "demo", | |
| 334 | name: "theme", | |
| 335 | version: "1.0.0", | |
| 336 | license: "MIT", | |
| 337 | contributes: { | |
| 338 | themes: [ | |
| 339 | { label: "Demo Dark", uiTheme: "vs-dark", path: "./themes/demo-dark.json" }, | |
| 340 | { label: "Demo Light", uiTheme: "vs", path: "./themes/demo-light.json" }, | |
| 341 | { label: "Demo", uiTheme: "vs-dark", path: "./themes/demo.json" }, | |
| 342 | ], | |
| 343 | }, | |
| 344 | }; | |
| 345 | let packageBytes = new ArrayBuffer(0); | |
| 346 | let checksum = ""; | |
| 347 | const rebuildPackage = async () => { | |
| 348 | zip.file("extension/package.json", JSON.stringify(packagedManifest)); | |
| 349 | packageBytes = await zip.generateAsync({ | |
| 350 | type: "arraybuffer", | |
| 351 | comment: `PK\u0005\u0006${"x".repeat(26)}`, | |
| 352 | }); | |
| 353 | checksum = [...sha256(new Uint8Array(packageBytes))] | |
| 354 | .map((byte) => byte.toString(16).padStart(2, "0")) | |
| 355 | .join(""); | |
| 356 | }; | |
| 357 | await rebuildPackage(); | |
| 358 | // The public manifest is only a preflight hint. Imports must use the manifest | |
| 359 | // inside the checksummed VSIX rather than this inconsistent contribution. | |
| 360 | const manifest = { | |
| 361 | contributes: { themes: [{ label: "Wrong", path: "./themes/missing.json" }] }, | |
| 362 | }; | |
| 363 | const extension: OpenVsxThemeExtension = { | |
| 364 | collectionId: "open-vsx:demo.theme", | |
| 365 | id: "demo.theme", | |
| 366 | name: "Demo Theme", | |
| 367 | publisher: "demo", | |
| 368 | description: "", | |
| 369 | downloadCount: 1, | |
| 370 | iconUrl: null, | |
| 371 | sourceUrl: null, | |
| 372 | license: "MIT", | |
| 373 | manifestUrl: `${ASSET_ROOT}/package.json`, | |
| 374 | sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`, | |
| 375 | version: "1.0.0", | |
| 376 | vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`, | |
| 377 | }; | |
| 378 | vi.stubGlobal( | |
| 379 | "fetch", | |
| 380 | vi.fn(async (input: string | URL | Request) => { | |
| 381 | const url = String(input); | |
| 382 | if (url === extension.manifestUrl) { | |
| 383 | return new Response(JSON.stringify(manifest), { status: 200 }); | |
| 384 | } | |
| 385 | if (url === extension.sha256Url) return new Response(`${checksum} demo.vsix`); | |
| 386 | return new Response(packageBytes, { | |
| 387 | status: 200, | |
| 388 | headers: { "Content-Length": String(packageBytes.byteLength) }, | |
| 389 | }); | |
| 390 | }), | |
| 391 | ); | |
| 392 | ||
| 393 | const themes = await importOpenVsxThemeExtension(extension); | |
| 394 | ||
| 395 | expect(themes).toHaveLength(2); | |
| 396 | expect(new Set(themes.map((theme) => theme.id)).size).toBe(2); | |
| 397 | expect(themes.every((theme) => /^ovx-[a-z0-9-]+-[0-9a-f]{12}$/.test(theme.id))).toBe(true); | |
| 398 | expect(themes.every((theme) => theme.collection?.id === "open-vsx:demo.theme")).toBe(true); | |
| 399 | const paired = themes.find( | |
| 400 | (theme) => | |
| 401 | getThemeColorsForMode(theme, "light") !== null && | |
| 402 | getThemeColorsForMode(theme, "dark") !== null, | |
| 403 | )!; | |
| 404 | expect(paired.label).toBe("Demo"); | |
| 405 | expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa"); | |
| 406 | expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111"); | |
| 407 | expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee"); | |
| 408 | ||
| 409 | packagedManifest.contributes.themes[0]!.label = "Renamed Dark"; | |
| 410 | packagedManifest.contributes.themes[1]!.label = "Renamed Light"; | |
| 411 | packagedManifest.contributes.themes[2]!.label = "Renamed Solo"; | |
| 412 | await rebuildPackage(); | |
| 413 | const renamedThemes = await importOpenVsxThemeExtension(extension); | |
| 414 | expect(renamedThemes.map((theme) => theme.id)).toEqual(themes.map((theme) => theme.id)); | |
| 415 | ||
| 416 | packagedManifest.contributes.themes.push({ | |
| 417 | label: "Duplicate Demo", | |
| 418 | uiTheme: "vs-dark", | |
| 419 | path: "./themes/demo.json", | |
| 420 | }); | |
| 421 | await rebuildPackage(); | |
| 422 | const duplicatePathThemes = await importOpenVsxThemeExtension(extension); | |
| 423 | expect(new Set(duplicatePathThemes.map((theme) => theme.id)).size).toBe( | |
| 424 | duplicatePathThemes.length, | |
| 425 | ); | |
| 426 | expect(themes.every(({ id }) => duplicatePathThemes.some((theme) => theme.id === id))).toBe( | |
| 427 | true, | |
| 428 | ); | |
| 429 | packagedManifest.contributes.themes.pop(); | |
| 430 | ||
| 431 | packagedManifest.contributes.themes[2]!.path = "./themes/missing.json"; | |
| 432 | await rebuildPackage(); | |
| 433 | await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow( | |
| 434 | "could not be imported safely", | |
| 435 | ); | |
| 436 | packagedManifest.contributes.themes[2]!.path = "./themes/demo.json"; | |
| 437 | ||
| 438 | packagedManifest.license = "Proprietary"; | |
| 439 | await rebuildPackage(); | |
| 440 | await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow( | |
| 441 | "does not match its advertised license", | |
| 442 | ); | |
| 443 | ||
| 444 | Reflect.deleteProperty(packagedManifest, "license"); | |
| 445 | await rebuildPackage(); | |
| 446 | await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow( | |
| 447 | "does not match its advertised license", | |
| 448 | ); | |
| 449 | }); | |
| 450 | ||
| 451 | it("stops import work when the request is cancelled", async () => { | |
| 452 | const packageBytes = new Uint8Array([1, 2, 3]); | |
| 453 | const checksum = [...sha256(packageBytes)] | |
| 454 | .map((byte) => byte.toString(16).padStart(2, "0")) | |
| 455 | .join(""); | |
| 456 | const controller = new AbortController(); | |
| 457 | const extension: OpenVsxThemeExtension = { | |
| 458 | collectionId: "open-vsx:demo.theme", | |
| 459 | id: "demo.theme", | |
| 460 | name: "Demo Theme", | |
| 461 | publisher: "demo", | |
| 462 | description: "", | |
| 463 | downloadCount: 1, | |
| 464 | iconUrl: null, | |
| 465 | sourceUrl: null, | |
| 466 | license: "MIT", | |
| 467 | manifestUrl: `${ASSET_ROOT}/package.json`, | |
| 468 | sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`, | |
| 469 | version: "1.0.0", | |
| 470 | vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`, | |
| 471 | }; | |
| 472 | vi.stubGlobal( | |
| 473 | "fetch", | |
| 474 | vi.fn(async (input: string | URL | Request) => { | |
| 475 | const url = String(input); | |
| 476 | if (url === extension.manifestUrl) { | |
| 477 | return new Response( | |
| 478 | JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }), | |
| 479 | ); | |
| 480 | } | |
| 481 | if (url === extension.sha256Url) { | |
| 482 | controller.abort(); | |
| 483 | return new Response(checksum); | |
| 484 | } | |
| 485 | return new Response(packageBytes); | |
| 486 | }), | |
| 487 | ); | |
| 488 | ||
| 489 | await expect(importOpenVsxThemeExtension(extension, controller.signal)).rejects.toMatchObject({ | |
| 490 | name: "AbortError", | |
| 491 | }); | |
| 492 | }); | |
| 493 | ||
| 494 | it("rejects a package whose Open VSX checksum does not match", async () => { | |
| 495 | const extension: OpenVsxThemeExtension = { | |
| 496 | collectionId: "open-vsx:demo.theme", | |
| 497 | id: "demo.theme", | |
| 498 | name: "Demo Theme", | |
| 499 | publisher: "demo", | |
| 500 | description: "", | |
| 501 | downloadCount: 1, | |
| 502 | iconUrl: null, | |
| 503 | sourceUrl: null, | |
| 504 | license: "MIT", | |
| 505 | manifestUrl: `${ASSET_ROOT}/package.json`, | |
| 506 | sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`, | |
| 507 | version: "1.0.0", | |
| 508 | vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`, | |
| 509 | }; | |
| 510 | vi.stubGlobal( | |
| 511 | "fetch", | |
| 512 | vi.fn(async (input: string | URL | Request) => { | |
| 513 | const url = String(input); | |
| 514 | if (url === extension.manifestUrl) { | |
| 515 | return new Response( | |
| 516 | JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }), | |
| 517 | ); | |
| 518 | } | |
| 519 | if (url === extension.sha256Url) return new Response("0".repeat(64)); | |
| 520 | return new Response(new Uint8Array([1, 2, 3])); | |
| 521 | }), | |
| 522 | ); | |
| 523 | ||
| 524 | await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow("integrity check"); | |
| 525 | }); | |
| 526 | }); |
apps/web/src/openVsxThemes.ts created+776| ... | ... | @@ -0,0 +1,776 @@ |
| 1 | import { sha256 } from "@noble/hashes/sha2"; | |
| 2 | import JSZip from "jszip"; | |
| 3 | import { parse, type ParseError } from "jsonc-parser"; | |
| 4 | ||
| 5 | import type { ThemeDefinition } from "./themePalette"; | |
| 6 | import { | |
| 7 | isVsCodeThemeFile, | |
| 8 | pairVsCodeThemes, | |
| 9 | parseVsCodeThemeFile, | |
| 10 | resolveThemeLabelCollisions, | |
| 11 | } from "./vscodeThemeImport"; | |
| 12 | ||
| 13 | const OPEN_VSX_SEARCH_URL = "https://open-vsx.org/api/-/search"; | |
| 14 | const MAX_VSIX_BYTES = 20 * 1024 * 1024; | |
| 15 | const MAX_SEARCH_BYTES = 512 * 1024; | |
| 16 | const MAX_DETAIL_BYTES = 256 * 1024; | |
| 17 | const MAX_MANIFEST_BYTES = 256 * 1024; | |
| 18 | const SEARCH_REQUEST_TIMEOUT_MS = 10_000; | |
| 19 | const MAX_THEME_BYTES = 256 * 1024; | |
| 20 | const MAX_ZIP_ENTRIES = 2_000; | |
| 21 | const MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024; | |
| 22 | const MAX_COMPRESSION_RATIO = 200; | |
| 23 | const MAX_THEMES_PER_EXTENSION = 40; | |
| 24 | const MAX_INCLUDE_DEPTH = 8; | |
| 25 | const MAX_PACKAGE_PATH_LENGTH = 1_024; | |
| 26 | const MAX_COLOR_VALUE_LENGTH = 128; | |
| 27 | const MAX_RESOLVED_THEME_FILES = MAX_THEMES_PER_EXTENSION * MAX_INCLUDE_DEPTH; | |
| 28 | const SUPPORTED_LICENSES = new Set([ | |
| 29 | "0BSD", | |
| 30 | "Apache-2.0", | |
| 31 | "BSD-2-Clause", | |
| 32 | "BSD-3-Clause", | |
| 33 | "CC0-1.0", | |
| 34 | "ISC", | |
| 35 | "MIT", | |
| 36 | "MPL-2.0", | |
| 37 | "Unlicense", | |
| 38 | ]); | |
| 39 | const USED_WORKBENCH_COLORS = new Set([ | |
| 40 | "activityBar.background", | |
| 41 | "activityBarBadge.background", | |
| 42 | "badge.background", | |
| 43 | "button.background", | |
| 44 | "button.foreground", | |
| 45 | "contrastBorder", | |
| 46 | "descriptionForeground", | |
| 47 | "disabledForeground", | |
| 48 | "dropdown.background", | |
| 49 | "dropdown.border", | |
| 50 | "editor.background", | |
| 51 | "editor.foreground", | |
| 52 | "editor.selectionBackground", | |
| 53 | "editorCursor.foreground", | |
| 54 | "editorError.foreground", | |
| 55 | "editorGroup.border", | |
| 56 | "editorPane.background", | |
| 57 | "editorWarning.foreground", | |
| 58 | "editorWidget.background", | |
| 59 | "errorForeground", | |
| 60 | "focusBorder", | |
| 61 | "foreground", | |
| 62 | "input.border", | |
| 63 | "input.placeholderForeground", | |
| 64 | "list.activeSelectionBackground", | |
| 65 | "list.hoverBackground", | |
| 66 | "list.inactiveSelectionBackground", | |
| 67 | "menu.background", | |
| 68 | "panel.background", | |
| 69 | "panel.border", | |
| 70 | "progressBar.background", | |
| 71 | "quickInput.background", | |
| 72 | "scrollbarSlider.background", | |
| 73 | "sideBar.background", | |
| 74 | "sideBar.border", | |
| 75 | "sideBar.foreground", | |
| 76 | "terminal.background", | |
| 77 | "terminal.foreground", | |
| 78 | "terminal.selectionBackground", | |
| 79 | "terminalCursor.foreground", | |
| 80 | "textCodeBlock.background", | |
| 81 | "textLink.foreground", | |
| 82 | ]); | |
| 83 | ||
| 84 | export type OpenVsxThemeSort = "downloadCount" | "rating" | "timestamp" | "relevance"; | |
| 85 | ||
| 86 | export type OpenVsxThemeExtension = { | |
| 87 | id: string; | |
| 88 | collectionId: string; | |
| 89 | name: string; | |
| 90 | publisher: string; | |
| 91 | description: string; | |
| 92 | downloadCount: number; | |
| 93 | iconUrl: string | null; | |
| 94 | sourceUrl: string | null; | |
| 95 | manifestUrl: string; | |
| 96 | sha256Url: string; | |
| 97 | vsixUrl: string; | |
| 98 | version: string; | |
| 99 | license: string; | |
| 100 | }; | |
| 101 | ||
| 102 | export type OpenVsxThemeSearchOptions = { | |
| 103 | signal?: AbortSignal; | |
| 104 | sortBy?: OpenVsxThemeSort; | |
| 105 | }; | |
| 106 | ||
| 107 | type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown }; | |
| 108 | ||
| 109 | function isRecord(value: unknown): value is Record<string, unknown> { | |
| 110 | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 111 | } | |
| 112 | ||
| 113 | function shortHash(value: string): string { | |
| 114 | return [...sha256(new TextEncoder().encode(value))] | |
| 115 | .slice(0, 6) | |
| 116 | .map((byte) => byte.toString(16).padStart(2, "0")) | |
| 117 | .join(""); | |
| 118 | } | |
| 119 | ||
| 120 | function openVsxThemeId(extensionId: string, source: string): string { | |
| 121 | return `ovx-theme-${shortHash(`${extensionId}:${source}`)}`; | |
| 122 | } | |
| 123 | ||
| 124 | function openVsxCollectionId(extensionId: string): string { | |
| 125 | const normalized = `open-vsx:${extensionId.toLowerCase()}`; | |
| 126 | return /^[a-z0-9][a-z0-9.:-]{0,127}$/.test(normalized) | |
| 127 | ? normalized | |
| 128 | : `open-vsx:${shortHash(extensionId)}`; | |
| 129 | } | |
| 130 | ||
| 131 | function trustedOpenVsxUrl(value: unknown): string | null { | |
| 132 | if (typeof value !== "string") return null; | |
| 133 | try { | |
| 134 | const url = new URL(value); | |
| 135 | return url.protocol === "https:" && url.hostname.toLowerCase() === "open-vsx.org" | |
| 136 | ? url.toString() | |
| 137 | : null; | |
| 138 | } catch { | |
| 139 | return null; | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | function publicSourceUrl(value: unknown): string | null { | |
| 144 | const rawValue = | |
| 145 | typeof value === "string" | |
| 146 | ? value | |
| 147 | : isRecord(value) && typeof value.url === "string" | |
| 148 | ? value.url | |
| 149 | : null; | |
| 150 | if (!rawValue) return null; | |
| 151 | try { | |
| 152 | const url = new URL(rawValue); | |
| 153 | return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null; | |
| 154 | } catch { | |
| 155 | return null; | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | function themeContributions(manifest: Record<string, unknown>): ThemeContribution[] { | |
| 160 | const contributes = isRecord(manifest.contributes) ? manifest.contributes : null; | |
| 161 | return Array.isArray(contributes?.themes) | |
| 162 | ? (contributes.themes.filter(isRecord) as ThemeContribution[]) | |
| 163 | : []; | |
| 164 | } | |
| 165 | ||
| 166 | function manifestLicenseMatches(manifest: Record<string, unknown>, license: string): boolean { | |
| 167 | return ( | |
| 168 | typeof manifest.license === "string" && | |
| 169 | manifest.license.trim().toLowerCase() === license.toLowerCase() | |
| 170 | ); | |
| 171 | } | |
| 172 | ||
| 173 | function extensionFromDetail(value: unknown): OpenVsxThemeExtension | null { | |
| 174 | if (!isRecord(value) || !isRecord(value.files)) { | |
| 175 | throw new Error("Open VSX returned malformed theme details."); | |
| 176 | } | |
| 177 | const namespace = typeof value.namespace === "string" ? value.namespace.trim() : ""; | |
| 178 | const extensionName = typeof value.name === "string" ? value.name.trim() : ""; | |
| 179 | const displayName = | |
| 180 | (typeof value.displayName === "string" ? value.displayName.trim() : "") || extensionName; | |
| 181 | const version = typeof value.version === "string" ? value.version.trim() : ""; | |
| 182 | const license = typeof value.license === "string" ? value.license.trim() : ""; | |
| 183 | const manifestUrl = trustedOpenVsxUrl(value.files.manifest); | |
| 184 | const sha256Url = trustedOpenVsxUrl(value.files.sha256); | |
| 185 | const vsixUrl = trustedOpenVsxUrl(value.files.download); | |
| 186 | if (!namespace || !extensionName || !version || !manifestUrl || !sha256Url || !vsixUrl) { | |
| 187 | throw new Error("Open VSX returned malformed theme details."); | |
| 188 | } | |
| 189 | if (!SUPPORTED_LICENSES.has(license)) return null; | |
| 190 | const id = `${namespace}.${extensionName}`; | |
| 191 | return { | |
| 192 | id, | |
| 193 | collectionId: openVsxCollectionId(id), | |
| 194 | name: displayName, | |
| 195 | publisher: namespace, | |
| 196 | description: typeof value.description === "string" ? value.description : "", | |
| 197 | downloadCount: | |
| 198 | typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount) | |
| 199 | ? value.downloadCount | |
| 200 | : 0, | |
| 201 | iconUrl: trustedOpenVsxUrl(value.files.icon), | |
| 202 | sourceUrl: | |
| 203 | publicSourceUrl(value.repository) ?? | |
| 204 | publicSourceUrl(value.homepage) ?? | |
| 205 | publicSourceUrl(value.url), | |
| 206 | manifestUrl, | |
| 207 | sha256Url, | |
| 208 | vsixUrl, | |
| 209 | version, | |
| 210 | license, | |
| 211 | }; | |
| 212 | } | |
| 213 | ||
| 214 | async function withSearchTimeout<T>( | |
| 215 | operation: (signal: AbortSignal) => Promise<T>, | |
| 216 | parentSignal?: AbortSignal, | |
| 217 | ): Promise<T> { | |
| 218 | const controller = new AbortController(); | |
| 219 | const abort = () => controller.abort(); | |
| 220 | if (parentSignal?.aborted) abort(); | |
| 221 | else parentSignal?.addEventListener("abort", abort, { once: true }); | |
| 222 | const timeout = setTimeout(abort, SEARCH_REQUEST_TIMEOUT_MS); | |
| 223 | try { | |
| 224 | return await operation(controller.signal); | |
| 225 | } catch (cause) { | |
| 226 | if (controller.signal.aborted && !parentSignal?.aborted) { | |
| 227 | throw new Error("Open VSX took too long to respond.", { cause }); | |
| 228 | } | |
| 229 | throw cause; | |
| 230 | } finally { | |
| 231 | clearTimeout(timeout); | |
| 232 | parentSignal?.removeEventListener("abort", abort); | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | export async function searchOpenVsxThemes( | |
| 237 | query: string, | |
| 238 | { signal, sortBy = "downloadCount" }: OpenVsxThemeSearchOptions = {}, | |
| 239 | ): Promise<OpenVsxThemeExtension[]> { | |
| 240 | const searchText = query.trim(); | |
| 241 | if (!searchText) return []; | |
| 242 | const url = new URL(OPEN_VSX_SEARCH_URL); | |
| 243 | url.searchParams.set("query", searchText); | |
| 244 | url.searchParams.set("category", "Themes"); | |
| 245 | url.searchParams.set("sortBy", sortBy); | |
| 246 | url.searchParams.set("sortOrder", "desc"); | |
| 247 | // Ask for a few extras because results without a supported SPDX license | |
| 248 | // are intentionally omitted. | |
| 249 | url.searchParams.set("size", "16"); | |
| 250 | const value = await withSearchTimeout(async (requestSignal) => { | |
| 251 | const response = await fetch(url, { signal: requestSignal }); | |
| 252 | if (!response.ok) throw new Error("Open VSX search is unavailable right now."); | |
| 253 | const searchBytes = await readCappedResponse( | |
| 254 | response, | |
| 255 | MAX_SEARCH_BYTES, | |
| 256 | "Open VSX returned an unexpectedly large response.", | |
| 257 | ); | |
| 258 | try { | |
| 259 | return JSON.parse(new TextDecoder().decode(searchBytes)) as unknown; | |
| 260 | } catch { | |
| 261 | throw new Error("Open VSX returned an unreadable response."); | |
| 262 | } | |
| 263 | }, signal); | |
| 264 | if (!isRecord(value) || !Array.isArray(value.extensions)) { | |
| 265 | throw new Error("Open VSX returned an unreadable search response."); | |
| 266 | } | |
| 267 | const identities = value.extensions.flatMap((candidate): Array<[string, string]> => { | |
| 268 | if (!isRecord(candidate)) return []; | |
| 269 | const namespace = typeof candidate.namespace === "string" ? candidate.namespace : ""; | |
| 270 | const name = typeof candidate.name === "string" ? candidate.name : ""; | |
| 271 | return namespace && name ? [[namespace, name]] : []; | |
| 272 | }); | |
| 273 | const details = await Promise.allSettled( | |
| 274 | identities.slice(0, 16).map(([namespace, name]) => | |
| 275 | withSearchTimeout(async (requestSignal) => { | |
| 276 | const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`; | |
| 277 | const detailResponse = await fetch(detailUrl, { signal: requestSignal }); | |
| 278 | if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable."); | |
| 279 | const detailBytes = await readCappedResponse( | |
| 280 | detailResponse, | |
| 281 | MAX_DETAIL_BYTES, | |
| 282 | "Open VSX returned an unexpectedly large detail response.", | |
| 283 | ); | |
| 284 | try { | |
| 285 | const extension = extensionFromDetail(JSON.parse(new TextDecoder().decode(detailBytes))); | |
| 286 | if (!extension) return null; | |
| 287 | const [manifestResponse, packageResponse] = await Promise.all([ | |
| 288 | fetch(extension.manifestUrl, { signal: requestSignal }), | |
| 289 | fetch(extension.vsixUrl, { method: "HEAD", signal: requestSignal }), | |
| 290 | ]); | |
| 291 | if (!manifestResponse.ok) throw new Error("manifest unavailable"); | |
| 292 | if (!packageResponse.ok) return null; | |
| 293 | const packageLength = Number(packageResponse.headers.get("content-length")); | |
| 294 | if (Number.isFinite(packageLength) && packageLength > MAX_VSIX_BYTES) { | |
| 295 | return null; | |
| 296 | } | |
| 297 | const manifestBytes = await readCappedResponse( | |
| 298 | manifestResponse, | |
| 299 | MAX_MANIFEST_BYTES, | |
| 300 | "Open VSX returned an unexpectedly large manifest.", | |
| 301 | ); | |
| 302 | const manifest = parseJsoncObject( | |
| 303 | new TextDecoder().decode(manifestBytes), | |
| 304 | "Extension manifest", | |
| 305 | ); | |
| 306 | return themeContributions(manifest).length > 0 && | |
| 307 | manifestLicenseMatches(manifest, extension.license) | |
| 308 | ? extension | |
| 309 | : null; | |
| 310 | } catch { | |
| 311 | throw new Error("Open VSX returned unreadable theme details."); | |
| 312 | } | |
| 313 | }, signal), | |
| 314 | ), | |
| 315 | ); | |
| 316 | if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError"); | |
| 317 | const completedDetails = details.filter((result) => result.status === "fulfilled"); | |
| 318 | if (identities.length > 0 && completedDetails.length === 0) { | |
| 319 | throw new Error("Open VSX theme details are unavailable right now."); | |
| 320 | } | |
| 321 | return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8); | |
| 322 | } | |
| 323 | ||
| 324 | function parseJsoncObject(source: string, description: string): Record<string, unknown> { | |
| 325 | const errors: ParseError[] = []; | |
| 326 | const value: unknown = parse(source, errors, { allowTrailingComma: true }); | |
| 327 | if (errors.length > 0 || !isRecord(value)) throw new Error(`${description} is not valid JSON.`); | |
| 328 | return value; | |
| 329 | } | |
| 330 | ||
| 331 | function sanitizeThemeObject(value: Record<string, unknown>): Record<string, unknown> { | |
| 332 | const colors: Record<string, string> = {}; | |
| 333 | if (isRecord(value.colors)) { | |
| 334 | for (const [key, color] of Object.entries(value.colors)) { | |
| 335 | if ( | |
| 336 | USED_WORKBENCH_COLORS.has(key) && | |
| 337 | typeof color === "string" && | |
| 338 | color.length <= MAX_COLOR_VALUE_LENGTH | |
| 339 | ) { | |
| 340 | colors[key] = color; | |
| 341 | } | |
| 342 | } | |
| 343 | } | |
| 344 | return { | |
| 345 | ...(typeof value.include === "string" ? { include: value.include } : {}), | |
| 346 | colors, | |
| 347 | }; | |
| 348 | } | |
| 349 | ||
| 350 | function normalizePackagePath(path: string, relativeTo = "extension/"): string { | |
| 351 | if ( | |
| 352 | path.length > MAX_PACKAGE_PATH_LENGTH || | |
| 353 | path.includes("\0") || | |
| 354 | path.startsWith("/") || | |
| 355 | /^[a-zA-Z]:/.test(path) | |
| 356 | ) { | |
| 357 | throw new Error("Theme path is not a safe relative package path."); | |
| 358 | } | |
| 359 | const normalizedInput = path.replaceAll("\\", "/"); | |
| 360 | const baseSegments = relativeTo.split("/").slice(0, -1); | |
| 361 | const segments = baseSegments; | |
| 362 | for (const segment of normalizedInput.split("/")) { | |
| 363 | if (!segment || segment === ".") continue; | |
| 364 | if (segment === "..") { | |
| 365 | if (segments.length <= 1) throw new Error("Theme path escapes the extension package."); | |
| 366 | segments.pop(); | |
| 367 | continue; | |
| 368 | } | |
| 369 | segments.push(segment); | |
| 370 | } | |
| 371 | if (segments[0] !== "extension") segments.unshift("extension"); | |
| 372 | return segments.join("/"); | |
| 373 | } | |
| 374 | ||
| 375 | function contributionType(uiTheme: unknown): string | null { | |
| 376 | if (uiTheme === "vs") return "light"; | |
| 377 | if (uiTheme === "vs-dark") return "dark"; | |
| 378 | if (uiTheme === "hc-black" || uiTheme === "hc-light") return uiTheme; | |
| 379 | return null; | |
| 380 | } | |
| 381 | ||
| 382 | type ZipEntrySizes = { | |
| 383 | uncompressedSize?: unknown; | |
| 384 | }; | |
| 385 | ||
| 386 | type InspectableZipObject = JSZip.JSZipObject & { | |
| 387 | _data?: ZipEntrySizes; | |
| 388 | unsafeOriginalName?: string; | |
| 389 | internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper<Uint8Array>; | |
| 390 | }; | |
| 391 | ||
| 392 | function inspectZipDirectory(bytes: Uint8Array): Uint8Array { | |
| 393 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| 394 | const minimumOffset = Math.max(0, bytes.byteLength - 65_557); | |
| 395 | let endOffset = bytes.byteLength - 22; | |
| 396 | while ( | |
| 397 | endOffset >= minimumOffset && | |
| 398 | (view.getUint32(endOffset, true) !== 0x06054b50 || | |
| 399 | endOffset + 22 + view.getUint16(endOffset + 20, true) !== bytes.byteLength) | |
| 400 | ) { | |
| 401 | endOffset -= 1; | |
| 402 | } | |
| 403 | if (endOffset < minimumOffset) throw new Error("That extension package has no ZIP directory."); | |
| 404 | ||
| 405 | const directorySize = view.getUint32(endOffset + 12, true); | |
| 406 | const directoryOffset = view.getUint32(endOffset + 16, true); | |
| 407 | const directoryEnd = directoryOffset + directorySize; | |
| 408 | if (directoryEnd !== endOffset || directoryEnd > bytes.byteLength) { | |
| 409 | throw new Error("That extension package has an invalid ZIP directory."); | |
| 410 | } | |
| 411 | ||
| 412 | let entryCount = 0; | |
| 413 | let totalUncompressed = 0; | |
| 414 | let offset = directoryOffset; | |
| 415 | while (offset < directoryEnd) { | |
| 416 | if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) { | |
| 417 | throw new Error("That extension package has an invalid ZIP directory."); | |
| 418 | } | |
| 419 | entryCount += 1; | |
| 420 | if (entryCount > MAX_ZIP_ENTRIES) { | |
| 421 | throw new Error("That extension package has too many files."); | |
| 422 | } | |
| 423 | const compressed = view.getUint32(offset + 20, true); | |
| 424 | const uncompressed = view.getUint32(offset + 24, true); | |
| 425 | if (compressed === 0xffffffff || uncompressed === 0xffffffff) { | |
| 426 | throw new Error("That extension package has unsupported ZIP64 metadata."); | |
| 427 | } | |
| 428 | totalUncompressed += uncompressed; | |
| 429 | if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) { | |
| 430 | throw new Error("That extension package expands beyond the safe import limit."); | |
| 431 | } | |
| 432 | if ( | |
| 433 | uncompressed > 0 && | |
| 434 | (compressed === 0 || uncompressed / compressed > MAX_COMPRESSION_RATIO) | |
| 435 | ) { | |
| 436 | throw new Error("That extension package has an unsafe compression ratio."); | |
| 437 | } | |
| 438 | const nameLength = view.getUint16(offset + 28, true); | |
| 439 | const extraLength = view.getUint16(offset + 30, true); | |
| 440 | const commentLength = view.getUint16(offset + 32, true); | |
| 441 | offset += 46 + nameLength + extraLength + commentLength; | |
| 442 | } | |
| 443 | if (offset !== directoryEnd) | |
| 444 | throw new Error("That extension package has an invalid ZIP directory."); | |
| 445 | ||
| 446 | const commentLength = view.getUint16(endOffset + 20, true); | |
| 447 | if (commentLength === 0) return bytes; | |
| 448 | ||
| 449 | // JSZip mistakes EOCD-like bytes inside an archive comment for the real EOCD. | |
| 450 | // The comment is not needed for theme import, so remove it before parsing. | |
| 451 | const withoutComment = bytes.slice(0, endOffset + 22); | |
| 452 | withoutComment[endOffset + 20] = 0; | |
| 453 | withoutComment[endOffset + 21] = 0; | |
| 454 | return withoutComment; | |
| 455 | } | |
| 456 | ||
| 457 | function inspectZip(zip: JSZip): void { | |
| 458 | const entries = Object.values(zip.files) as InspectableZipObject[]; | |
| 459 | if (entries.length > MAX_ZIP_ENTRIES) | |
| 460 | throw new Error("That extension package has too many files."); | |
| 461 | ||
| 462 | for (const entry of entries) { | |
| 463 | if (entry.unsafeOriginalName) normalizePackagePath(entry.unsafeOriginalName); | |
| 464 | } | |
| 465 | } | |
| 466 | ||
| 467 | async function readZipText( | |
| 468 | zip: JSZip, | |
| 469 | path: string, | |
| 470 | description: string, | |
| 471 | signal?: AbortSignal, | |
| 472 | ): Promise<string> { | |
| 473 | signal?.throwIfAborted(); | |
| 474 | const file = zip.file(path) as InspectableZipObject | null; | |
| 475 | if (!file) throw new Error(`${description} is missing from the extension package.`); | |
| 476 | if (typeof file._data?.uncompressedSize !== "number" || !file.internalStream) { | |
| 477 | throw new Error(`${description} has unreadable size metadata.`); | |
| 478 | } | |
| 479 | if (file._data.uncompressedSize > MAX_THEME_BYTES) { | |
| 480 | throw new Error(`${description} is too large.`); | |
| 481 | } | |
| 482 | ||
| 483 | return new Promise((resolve, reject) => { | |
| 484 | const chunks: Uint8Array[] = []; | |
| 485 | let byteLength = 0; | |
| 486 | let settled = false; | |
| 487 | const stream = file.internalStream!("uint8array"); | |
| 488 | const cleanup = () => signal?.removeEventListener("abort", handleAbort); | |
| 489 | const handleAbort = () => { | |
| 490 | if (settled) return; | |
| 491 | settled = true; | |
| 492 | stream.pause(); | |
| 493 | cleanup(); | |
| 494 | reject(signal?.reason); | |
| 495 | }; | |
| 496 | signal?.addEventListener("abort", handleAbort, { once: true }); | |
| 497 | stream | |
| 498 | .on("data", (chunk) => { | |
| 499 | if (settled) return; | |
| 500 | byteLength += chunk.byteLength; | |
| 501 | if (byteLength > MAX_THEME_BYTES) { | |
| 502 | settled = true; | |
| 503 | stream.pause(); | |
| 504 | cleanup(); | |
| 505 | reject(new Error(`${description} is too large.`)); | |
| 506 | return; | |
| 507 | } | |
| 508 | chunks.push(chunk); | |
| 509 | }) | |
| 510 | .on("error", (cause) => { | |
| 511 | if (settled) return; | |
| 512 | settled = true; | |
| 513 | cleanup(); | |
| 514 | reject(cause); | |
| 515 | }) | |
| 516 | .on("end", () => { | |
| 517 | if (settled) return; | |
| 518 | settled = true; | |
| 519 | cleanup(); | |
| 520 | const bytes = new Uint8Array(byteLength); | |
| 521 | let offset = 0; | |
| 522 | for (const chunk of chunks) { | |
| 523 | bytes.set(chunk, offset); | |
| 524 | offset += chunk.byteLength; | |
| 525 | } | |
| 526 | resolve(new TextDecoder().decode(bytes)); | |
| 527 | }) | |
| 528 | .resume(); | |
| 529 | }); | |
| 530 | } | |
| 531 | ||
| 532 | async function loadThemeObject( | |
| 533 | zip: JSZip, | |
| 534 | path: string, | |
| 535 | cache: Map<string, Record<string, unknown>>, | |
| 536 | budget: { files: number }, | |
| 537 | ancestors: ReadonlySet<string> = new Set(), | |
| 538 | signal?: AbortSignal, | |
| 539 | ): Promise<Record<string, unknown>> { | |
| 540 | signal?.throwIfAborted(); | |
| 541 | if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply."); | |
| 542 | if (ancestors.has(path)) throw new Error("Theme includes contain a cycle."); | |
| 543 | const cached = cache.get(path); | |
| 544 | if (cached) return cached; | |
| 545 | budget.files += 1; | |
| 546 | if (budget.files > MAX_RESOLVED_THEME_FILES) { | |
| 547 | throw new Error("That extension references too many theme files."); | |
| 548 | } | |
| 549 | ||
| 550 | const value = sanitizeThemeObject( | |
| 551 | parseJsoncObject(await readZipText(zip, path, path, signal), path), | |
| 552 | ); | |
| 553 | if (typeof value.include !== "string") { | |
| 554 | cache.set(path, value); | |
| 555 | return value; | |
| 556 | } | |
| 557 | ||
| 558 | const includePath = normalizePackagePath(value.include, path); | |
| 559 | const nextAncestors = new Set(ancestors); | |
| 560 | nextAncestors.add(path); | |
| 561 | const base = await loadThemeObject(zip, includePath, cache, budget, nextAncestors, signal); | |
| 562 | const resolved = { | |
| 563 | ...base, | |
| 564 | ...value, | |
| 565 | colors: { | |
| 566 | ...(isRecord(base.colors) ? base.colors : {}), | |
| 567 | ...(isRecord(value.colors) ? value.colors : {}), | |
| 568 | }, | |
| 569 | }; | |
| 570 | cache.set(path, resolved); | |
| 571 | return resolved; | |
| 572 | } | |
| 573 | ||
| 574 | async function readCappedResponse( | |
| 575 | response: Response, | |
| 576 | limit: number, | |
| 577 | tooLargeMessage: string, | |
| 578 | ): Promise<Uint8Array> { | |
| 579 | const contentLength = response.headers.get("content-length"); | |
| 580 | if (contentLength && Number(contentLength) > limit) throw new Error(tooLargeMessage); | |
| 581 | if (!response.body) { | |
| 582 | const bytes = new Uint8Array(await response.arrayBuffer()); | |
| 583 | if (bytes.byteLength > limit) throw new Error(tooLargeMessage); | |
| 584 | return bytes; | |
| 585 | } | |
| 586 | ||
| 587 | const reader = response.body.getReader(); | |
| 588 | const chunks: Uint8Array[] = []; | |
| 589 | let byteLength = 0; | |
| 590 | try { | |
| 591 | while (true) { | |
| 592 | const { done, value } = await reader.read(); | |
| 593 | if (done) break; | |
| 594 | byteLength += value.byteLength; | |
| 595 | if (byteLength > limit) { | |
| 596 | await reader.cancel(); | |
| 597 | throw new Error(tooLargeMessage); | |
| 598 | } | |
| 599 | chunks.push(value); | |
| 600 | } | |
| 601 | } finally { | |
| 602 | reader.releaseLock(); | |
| 603 | } | |
| 604 | ||
| 605 | const result = new Uint8Array(byteLength); | |
| 606 | let offset = 0; | |
| 607 | for (const chunk of chunks) { | |
| 608 | result.set(chunk, offset); | |
| 609 | offset += chunk.byteLength; | |
| 610 | } | |
| 611 | return result; | |
| 612 | } | |
| 613 | ||
| 614 | async function fetchPackage(url: string, signal?: AbortSignal): Promise<Uint8Array> { | |
| 615 | const response = await fetch(url, signal ? { signal } : {}); | |
| 616 | if (!response.ok) throw new Error("That Open VSX theme could not be downloaded."); | |
| 617 | return readCappedResponse( | |
| 618 | response, | |
| 619 | MAX_VSIX_BYTES, | |
| 620 | "That theme extension is too large to import safely.", | |
| 621 | ); | |
| 622 | } | |
| 623 | ||
| 624 | export async function importOpenVsxThemeExtension( | |
| 625 | extension: OpenVsxThemeExtension, | |
| 626 | signal?: AbortSignal, | |
| 627 | ): Promise<ReadonlyArray<ThemeDefinition>> { | |
| 628 | const manifestResponse = await fetch(extension.manifestUrl, signal ? { signal } : {}); | |
| 629 | if (!manifestResponse.ok) throw new Error("That Open VSX extension has no readable manifest."); | |
| 630 | const manifestBytes = await readCappedResponse( | |
| 631 | manifestResponse, | |
| 632 | MAX_MANIFEST_BYTES, | |
| 633 | "That Open VSX extension manifest is too large.", | |
| 634 | ); | |
| 635 | const manifest = parseJsoncObject(new TextDecoder().decode(manifestBytes), "Extension manifest"); | |
| 636 | const advertisedContributions = themeContributions(manifest); | |
| 637 | if (advertisedContributions.length === 0) { | |
| 638 | throw new Error("That extension does not contain color themes."); | |
| 639 | } | |
| 640 | if (advertisedContributions.length > MAX_THEMES_PER_EXTENSION) { | |
| 641 | throw new Error("That extension contains too many color themes to import safely."); | |
| 642 | } | |
| 643 | ||
| 644 | const packageBytes = await fetchPackage(extension.vsixUrl, signal); | |
| 645 | signal?.throwIfAborted(); | |
| 646 | const checksumResponse = await fetch(extension.sha256Url, signal ? { signal } : {}); | |
| 647 | if (!checksumResponse.ok) throw new Error("That Open VSX theme has no readable checksum."); | |
| 648 | const expectedChecksum = new TextDecoder() | |
| 649 | .decode( | |
| 650 | await readCappedResponse( | |
| 651 | checksumResponse, | |
| 652 | 256, | |
| 653 | "That Open VSX checksum response is invalid.", | |
| 654 | ), | |
| 655 | ) | |
| 656 | .trim() | |
| 657 | .split(/\s+/)[0]; | |
| 658 | if (!expectedChecksum || !/^[a-f\d]{64}$/i.test(expectedChecksum)) { | |
| 659 | throw new Error("That Open VSX theme has an invalid checksum."); | |
| 660 | } | |
| 661 | signal?.throwIfAborted(); | |
| 662 | const actualChecksum = [...sha256(packageBytes)] | |
| 663 | .map((byte) => byte.toString(16).padStart(2, "0")) | |
| 664 | .join(""); | |
| 665 | if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) { | |
| 666 | throw new Error("That Open VSX theme failed its integrity check."); | |
| 667 | } | |
| 668 | signal?.throwIfAborted(); | |
| 669 | let zip: JSZip; | |
| 670 | try { | |
| 671 | const inspectedPackageBytes = inspectZipDirectory(packageBytes); | |
| 672 | zip = await JSZip.loadAsync(inspectedPackageBytes); | |
| 673 | signal?.throwIfAborted(); | |
| 674 | inspectZip(zip); | |
| 675 | } catch (cause) { | |
| 676 | if (signal?.aborted) signal.throwIfAborted(); | |
| 677 | if (cause instanceof Error && cause.message.startsWith("That extension package")) throw cause; | |
| 678 | throw new Error("That Open VSX extension package could not be opened.", { cause }); | |
| 679 | } | |
| 680 | ||
| 681 | const packagedManifest = parseJsoncObject( | |
| 682 | await readZipText(zip, "extension/package.json", "Extension manifest", signal), | |
| 683 | "Extension manifest", | |
| 684 | ); | |
| 685 | if ( | |
| 686 | typeof packagedManifest.publisher !== "string" || | |
| 687 | packagedManifest.publisher.toLowerCase() !== extension.publisher.toLowerCase() || | |
| 688 | typeof packagedManifest.name !== "string" || | |
| 689 | `${packagedManifest.publisher}.${packagedManifest.name}`.toLowerCase() !== | |
| 690 | extension.id.toLowerCase() || | |
| 691 | packagedManifest.version !== extension.version | |
| 692 | ) { | |
| 693 | throw new Error("That extension package does not match the selected Open VSX theme."); | |
| 694 | } | |
| 695 | if (!manifestLicenseMatches(packagedManifest, extension.license)) { | |
| 696 | throw new Error("That extension package does not match its advertised license."); | |
| 697 | } | |
| 698 | const contributions = themeContributions(packagedManifest); | |
| 699 | if (contributions.length === 0) throw new Error("That extension does not contain color themes."); | |
| 700 | if (contributions.length > MAX_THEMES_PER_EXTENSION) { | |
| 701 | throw new Error("That extension contains too many color themes to import safely."); | |
| 702 | } | |
| 703 | ||
| 704 | const parsed: Array<{ theme: ThemeDefinition; sourceName: string; sourcePath: string }> = []; | |
| 705 | const failures: string[] = []; | |
| 706 | const themeCache = new Map<string, Record<string, unknown>>(); | |
| 707 | const themeBudget = { files: 0 }; | |
| 708 | for (const contribution of contributions) { | |
| 709 | signal?.throwIfAborted(); | |
| 710 | if (typeof contribution.path !== "string") { | |
| 711 | failures.push("theme path is missing"); | |
| 712 | continue; | |
| 713 | } | |
| 714 | try { | |
| 715 | const path = normalizePackagePath(contribution.path); | |
| 716 | const themeValue = await loadThemeObject( | |
| 717 | zip, | |
| 718 | path, | |
| 719 | themeCache, | |
| 720 | themeBudget, | |
| 721 | new Set(), | |
| 722 | signal, | |
| 723 | ); | |
| 724 | const type = contributionType(contribution.uiTheme); | |
| 725 | const label = | |
| 726 | typeof contribution.label === "string" && contribution.label.trim() | |
| 727 | ? contribution.label.trim() | |
| 728 | : extension.name; | |
| 729 | const decorated = { | |
| 730 | ...themeValue, | |
| 731 | displayName: label, | |
| 732 | ...(type ? { type } : {}), | |
| 733 | }; | |
| 734 | if (!isVsCodeThemeFile(decorated)) throw new Error("not a VS Code color theme"); | |
| 735 | parsed.push({ | |
| 736 | theme: parseVsCodeThemeFile(decorated), | |
| 737 | sourceName: path.split("/").at(-1)!, | |
| 738 | sourcePath: path, | |
| 739 | }); | |
| 740 | } catch (cause) { | |
| 741 | signal?.throwIfAborted(); | |
| 742 | failures.push(cause instanceof Error ? cause.message : "theme could not be read"); | |
| 743 | } | |
| 744 | } | |
| 745 | if (failures.length > 0) { | |
| 746 | throw new Error("One or more color themes in that extension could not be imported safely."); | |
| 747 | } | |
| 748 | if (parsed.length === 0) { | |
| 749 | throw new Error("That extension has no compatible color themes."); | |
| 750 | } | |
| 751 | const extensionId = extension.id.toLowerCase(); | |
| 752 | const sourcePathCounts = new Map<string, number>(); | |
| 753 | for (const { sourcePath } of parsed) { | |
| 754 | sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1); | |
| 755 | } | |
| 756 | const sourcePathOccurrences = new Map<string, number>(); | |
| 757 | const sourceIdentities = parsed.map(({ sourcePath }) => { | |
| 758 | if (sourcePathCounts.get(sourcePath) === 1) return sourcePath; | |
| 759 | const occurrence = sourcePathOccurrences.get(sourcePath) ?? 0; | |
| 760 | sourcePathOccurrences.set(sourcePath, occurrence + 1); | |
| 761 | return occurrence === 0 ? sourcePath : `${sourcePath}\0${occurrence}`; | |
| 762 | }); | |
| 763 | const resolved = resolveThemeLabelCollisions(parsed).map((theme, index) => ({ | |
| 764 | ...theme, | |
| 765 | id: openVsxThemeId(extensionId, sourceIdentities[index]!), | |
| 766 | })); | |
| 767 | const paired = pairVsCodeThemes(resolved, { | |
| 768 | pairedId: (light, dark) => openVsxThemeId(extensionId, [light.id, dark.id].sort().join(":")), | |
| 769 | }); | |
| 770 | const themes = resolveThemeLabelCollisions(paired.map((theme) => ({ theme }))); | |
| 771 | const collection = { | |
| 772 | id: extension.collectionId, | |
| 773 | label: extension.name.slice(0, 48), | |
| 774 | }; | |
| 775 | return themes.map((theme) => ({ ...theme, collection })); | |
| 776 | } |
apps/web/src/themePalette.test.ts+247| ... | ... | @@ -10,12 +10,15 @@ import { |
| 10 | 10 | getThemePreferenceMode, |
| 11 | 11 | isKnownThemePreference, |
| 12 | 12 | getCustomThemes, |
| 13 | getStoredCustomThemeCollection, | |
| 13 | 14 | invalidateCustomThemes, |
| 14 | 15 | installCustomTheme, |
| 15 | 16 | canonicalThemePreference, |
| 16 | 17 | parseThemeFile, |
| 17 | 18 | parseThemeHalves, |
| 18 | 19 | removeCustomTheme, |
| 20 | removeCustomThemes, | |
| 21 | replaceCustomThemeCollection, | |
| 19 | 22 | resolveDesktopTheme, |
| 20 | 23 | resolveThemeAppearance, |
| 21 | 24 | serializeThemeFile, |
| ... | ... | @@ -277,6 +280,28 @@ describe("theme files", () => { |
| 277 | 280 | }); |
| 278 | 281 | }); |
| 279 | 282 | |
| 283 | it("serializes a theme back into the importable file shape", () => { | |
| 284 | const theme = { | |
| 285 | ...parseThemeFile({ | |
| 286 | version: THEME_FILE_VERSION, | |
| 287 | id: "community-demo", | |
| 288 | name: "Community Demo", | |
| 289 | appearance: "dark", | |
| 290 | colors: { canvas: "#111111" }, | |
| 291 | }), | |
| 292 | collection: { id: "open-vsx:demo.theme", label: "Demo Theme" }, | |
| 293 | }; | |
| 294 | const serialized = serializeThemeFile(theme); | |
| 295 | expect(JSON.parse(serialized)).toMatchObject({ | |
| 296 | version: THEME_FILE_VERSION, | |
| 297 | id: theme.id, | |
| 298 | name: theme.label, | |
| 299 | appearance: "dark", | |
| 300 | collection: theme.collection, | |
| 301 | }); | |
| 302 | expect(parseThemeFile(JSON.parse(serialized)).collection).toEqual(theme.collection); | |
| 303 | }); | |
| 304 | ||
| 280 | 305 | it("keeps sidebar artwork disabled for custom theme files", () => { |
| 281 | 306 | const theme = parseThemeFile({ |
| 282 | 307 | version: THEME_FILE_VERSION, |
| ... | ... | @@ -491,6 +516,178 @@ describe("theme files", () => { |
| 491 | 516 | vi.unstubAllGlobals(); |
| 492 | 517 | }); |
| 493 | 518 | |
| 519 | it("preserves valid imported-theme collections and drops malformed metadata", () => { | |
| 520 | vi.stubGlobal("window", { | |
| 521 | localStorage: { | |
| 522 | getItem: (key: string) => | |
| 523 | key === CUSTOM_THEMES_STORAGE_KEY | |
| 524 | ? JSON.stringify([ | |
| 525 | { | |
| 526 | id: "github-dark", | |
| 527 | label: "GitHub Dark", | |
| 528 | appearance: "dark", | |
| 529 | colors: { canvas: "#0d1117" }, | |
| 530 | collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" }, | |
| 531 | }, | |
| 532 | { | |
| 533 | id: "github-light", | |
| 534 | label: "GitHub Light", | |
| 535 | appearance: "light", | |
| 536 | colors: { canvas: "#ffffff" }, | |
| 537 | collection: { id: "bad collection id", label: "GitHub Theme" }, | |
| 538 | }, | |
| 539 | ]) | |
| 540 | : null, | |
| 541 | }, | |
| 542 | }); | |
| 543 | ||
| 544 | invalidateCustomThemes(); | |
| 545 | expect(getCustomThemes()).toMatchObject([ | |
| 546 | { collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" } }, | |
| 547 | { id: "github-light" }, | |
| 548 | ]); | |
| 549 | expect(getCustomThemes()[1]).not.toHaveProperty("collection"); | |
| 550 | ||
| 551 | vi.unstubAllGlobals(); | |
| 552 | invalidateCustomThemes(); | |
| 553 | }); | |
| 554 | ||
| 555 | it("atomically replaces an imported collection and removes stale variants", () => { | |
| 556 | const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" }; | |
| 557 | const personalTheme = { | |
| 558 | id: "personal", | |
| 559 | label: "Personal", | |
| 560 | appearance: "dark", | |
| 561 | colors: { canvas: "#111111", futureRole: "hsl(10 20% 30%)" }, | |
| 562 | futureMetadata: { version: 2 }, | |
| 563 | }; | |
| 564 | const stored = new Map<string, string>([ | |
| 565 | [ | |
| 566 | CUSTOM_THEMES_STORAGE_KEY, | |
| 567 | JSON.stringify([ | |
| 568 | personalTheme, | |
| 569 | { | |
| 570 | id: "old-light", | |
| 571 | label: "Old Light", | |
| 572 | appearance: "light", | |
| 573 | colors: { canvas: "#ffffff" }, | |
| 574 | collection, | |
| 575 | }, | |
| 576 | { | |
| 577 | id: "removed-dark", | |
| 578 | label: "Removed Dark", | |
| 579 | appearance: "dark", | |
| 580 | colors: { canvas: "#000000" }, | |
| 581 | collection, | |
| 582 | }, | |
| 583 | ]), | |
| 584 | ], | |
| 585 | ]); | |
| 586 | const setItem = vi.fn((key: string, value: string) => stored.set(key, value)); | |
| 587 | vi.stubGlobal("window", { | |
| 588 | localStorage: { | |
| 589 | getItem: (key: string) => stored.get(key) ?? null, | |
| 590 | setItem, | |
| 591 | }, | |
| 592 | }); | |
| 593 | ||
| 594 | invalidateCustomThemes(); | |
| 595 | const expectedCollection = getStoredCustomThemeCollection(collection.id); | |
| 596 | getCustomThemes(); | |
| 597 | const concurrentlyAddedTheme = { | |
| 598 | id: "other-tab", | |
| 599 | label: "Other Tab", | |
| 600 | appearance: "dark", | |
| 601 | colors: { canvas: "#222222" }, | |
| 602 | }; | |
| 603 | stored.set( | |
| 604 | CUSTOM_THEMES_STORAGE_KEY, | |
| 605 | JSON.stringify([ | |
| 606 | ...JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]"), | |
| 607 | concurrentlyAddedTheme, | |
| 608 | ]), | |
| 609 | ); | |
| 610 | const replacement = [ | |
| 611 | { | |
| 612 | ...parseThemeFile({ | |
| 613 | version: THEME_FILE_VERSION, | |
| 614 | id: "old-light", | |
| 615 | name: "New Light", | |
| 616 | appearance: "light", | |
| 617 | colors: { canvas: "#fafafa" }, | |
| 618 | }), | |
| 619 | collection, | |
| 620 | }, | |
| 621 | { | |
| 622 | ...parseThemeFile({ | |
| 623 | version: THEME_FILE_VERSION, | |
| 624 | id: "new-dark", | |
| 625 | name: "New Dark", | |
| 626 | appearance: "dark", | |
| 627 | colors: { canvas: "#101010" }, | |
| 628 | }), | |
| 629 | collection, | |
| 630 | }, | |
| 631 | ]; | |
| 632 | ||
| 633 | expect( | |
| 634 | replaceCustomThemeCollection(collection.id, replacement, { expectedCollection }), | |
| 635 | ).toEqual(replacement); | |
| 636 | expect(getCustomThemes().map((theme) => theme.id)).toEqual([ | |
| 637 | "personal", | |
| 638 | "old-light", | |
| 639 | "new-dark", | |
| 640 | "other-tab", | |
| 641 | ]); | |
| 642 | expect(setItem).toHaveBeenCalledTimes(1); | |
| 643 | expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toEqual(personalTheme); | |
| 644 | ||
| 645 | vi.unstubAllGlobals(); | |
| 646 | invalidateCustomThemes(); | |
| 647 | }); | |
| 648 | ||
| 649 | it("replaces collection entries even when their stored collection label is malformed", () => { | |
| 650 | const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" }; | |
| 651 | const stored = new Map<string, string>([ | |
| 652 | [ | |
| 653 | CUSTOM_THEMES_STORAGE_KEY, | |
| 654 | JSON.stringify([ | |
| 655 | { | |
| 656 | id: "old-light", | |
| 657 | label: "Old Light", | |
| 658 | appearance: "light", | |
| 659 | colors: { canvas: "#ffffff" }, | |
| 660 | collection: { id: collection.id, label: 42 }, | |
| 661 | }, | |
| 662 | ]), | |
| 663 | ], | |
| 664 | ]); | |
| 665 | vi.stubGlobal("window", { | |
| 666 | localStorage: { | |
| 667 | getItem: (key: string) => stored.get(key) ?? null, | |
| 668 | setItem: (key: string, value: string) => stored.set(key, value), | |
| 669 | }, | |
| 670 | }); | |
| 671 | ||
| 672 | invalidateCustomThemes(); | |
| 673 | const replacement = { | |
| 674 | ...parseThemeFile({ | |
| 675 | version: THEME_FILE_VERSION, | |
| 676 | id: "old-light", | |
| 677 | name: "New Light", | |
| 678 | appearance: "light", | |
| 679 | colors: { canvas: "#fafafa" }, | |
| 680 | }), | |
| 681 | collection, | |
| 682 | }; | |
| 683 | ||
| 684 | expect(replaceCustomThemeCollection(collection.id, [replacement])).toEqual([replacement]); | |
| 685 | expect(getCustomThemes()).toEqual([replacement]); | |
| 686 | ||
| 687 | vi.unstubAllGlobals(); | |
| 688 | invalidateCustomThemes(); | |
| 689 | }); | |
| 690 | ||
| 494 | 691 | it("canonicalizes explicit writes without migrating untouched themes", () => { |
| 495 | 692 | const stored = new Map<string, string>(); |
| 496 | 693 | const untouchedTheme = { |
| ... | ... | @@ -551,6 +748,56 @@ describe("theme files", () => { |
| 551 | 748 | invalidateCustomThemes(); |
| 552 | 749 | }); |
| 553 | 750 | |
| 751 | it("removes multiple custom themes atomically while preserving unrelated entries", () => { | |
| 752 | const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" }; | |
| 753 | const personalTheme = { | |
| 754 | id: "personal", | |
| 755 | label: "Personal", | |
| 756 | appearance: "dark", | |
| 757 | colors: { canvas: "#111111", futureRole: "hsl(10 20% 30%)" }, | |
| 758 | futureMetadata: { version: 2 }, | |
| 759 | }; | |
| 760 | const stored = new Map<string, string>([ | |
| 761 | [ | |
| 762 | CUSTOM_THEMES_STORAGE_KEY, | |
| 763 | JSON.stringify([ | |
| 764 | personalTheme, | |
| 765 | { | |
| 766 | id: "demo-light", | |
| 767 | label: "Demo Light", | |
| 768 | appearance: "light", | |
| 769 | colors: { canvas: "#ffffff" }, | |
| 770 | collection, | |
| 771 | }, | |
| 772 | { | |
| 773 | id: "demo-dark", | |
| 774 | label: "Demo Dark", | |
| 775 | appearance: "dark", | |
| 776 | colors: { canvas: "#000000" }, | |
| 777 | collection, | |
| 778 | }, | |
| 779 | ]), | |
| 780 | ], | |
| 781 | ]); | |
| 782 | const setItem = vi.fn((key: string, value: string) => stored.set(key, value)); | |
| 783 | vi.stubGlobal("window", { | |
| 784 | localStorage: { | |
| 785 | getItem: (key: string) => stored.get(key) ?? null, | |
| 786 | setItem, | |
| 787 | }, | |
| 788 | }); | |
| 789 | ||
| 790 | invalidateCustomThemes(); | |
| 791 | removeCustomThemes(["demo-light", "demo-dark"]); | |
| 792 | ||
| 793 | expect(setItem).toHaveBeenCalledOnce(); | |
| 794 | expect(getCustomThemes()).toEqual([expect.objectContaining({ id: "personal" })]); | |
| 795 | expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toEqual([personalTheme]); | |
| 796 | ||
| 797 | vi.unstubAllGlobals(); | |
| 798 | invalidateCustomThemes(); | |
| 799 | }); | |
| 800 | ||
| 554 | 801 | it("writes from the cached raw snapshot without risking a destructive reread", () => { |
| 555 | 802 | const legacyTheme = { |
| 556 | 803 | id: "legacy", |
apps/web/src/themePalette.ts+133-13| ... | ... | @@ -92,12 +92,15 @@ export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string |
| 92 | 92 | export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>; |
| 93 | 93 | export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; |
| 94 | 94 | export type ThemePreferenceMode = ThemeAppearance | "system"; |
| 95 | export type ThemeCollection = Readonly<{ id: string; label: string }>; | |
| 95 | 96 | export type ThemeDefinition = Readonly<{ |
| 96 | 97 | id: string; |
| 97 | 98 | label: string; |
| 98 | 99 | appearance: ThemeAppearance; |
| 99 | 100 | colors: ThemeColors; |
| 100 | 101 | variants?: ThemeVariants; |
| 102 | /** Groups related imported variants into one library card. */ | |
| 103 | collection?: ThemeCollection; | |
| 101 | 104 | /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */ |
| 102 | 105 | sidebarArtwork?: boolean; |
| 103 | 106 | /** True when the palette was generated by the guided editor from its |
| ... | ... | @@ -111,6 +114,7 @@ export type ThemeFile = Readonly<{ |
| 111 | 114 | appearance: ThemeAppearance; |
| 112 | 115 | colors: ThemeColorOverrides; |
| 113 | 116 | variants?: ThemeVariantOverrides; |
| 117 | collection?: ThemeCollection; | |
| 114 | 118 | managed?: boolean; |
| 115 | 119 | }>; |
| 116 | 120 | |
| ... | ... | @@ -179,6 +183,15 @@ function isThemeLabel(value: unknown): value is string { |
| 179 | 183 | return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; |
| 180 | 184 | } |
| 181 | 185 | |
| 186 | function parseThemeCollection(value: unknown): ThemeCollection | undefined { | |
| 187 | return isRecord(value) && | |
| 188 | typeof value.id === "string" && | |
| 189 | /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.id) && | |
| 190 | isThemeLabel(value.label) | |
| 191 | ? { id: value.id, label: value.label.trim() } | |
| 192 | : undefined; | |
| 193 | } | |
| 194 | ||
| 182 | 195 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { |
| 183 | 196 | if (!isRecord(value)) return null; |
| 184 | 197 | |
| ... | ... | @@ -224,6 +237,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { |
| 224 | 237 | if (!colors) return null; |
| 225 | 238 | const variants = parseStoredThemeVariants(value.variants, value.appearance); |
| 226 | 239 | if (value.variants !== undefined && variants === null) return null; |
| 240 | const collection = parseThemeCollection(value.collection); | |
| 227 | 241 | |
| 228 | 242 | return { |
| 229 | 243 | id: value.id, |
| ... | ... | @@ -231,10 +245,22 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { |
| 231 | 245 | appearance: value.appearance, |
| 232 | 246 | colors, |
| 233 | 247 | ...(variants ? { variants } : {}), |
| 248 | ...(collection ? { collection } : {}), | |
| 234 | 249 | ...(value.managed === true ? { managed: true } : {}), |
| 235 | 250 | }; |
| 236 | 251 | } |
| 237 | 252 | |
| 253 | function parseStoredThemes(storedThemes: ReadonlyArray<unknown>): ReadonlyArray<ThemeDefinition> { | |
| 254 | const themes: ThemeDefinition[] = []; | |
| 255 | for (const value of storedThemes) { | |
| 256 | const theme = parseStoredTheme(value); | |
| 257 | if (theme && !themes.some((existing) => existing.id === theme.id)) { | |
| 258 | themes.push(theme); | |
| 259 | } | |
| 260 | } | |
| 261 | return themes; | |
| 262 | } | |
| 263 | ||
| 238 | 264 | function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| 239 | 265 | if (typeof window === "undefined") { |
| 240 | 266 | return { status: "ready", storedThemes: [], themes: [] }; |
| ... | ... | @@ -256,15 +282,7 @@ function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| 256 | 282 | } |
| 257 | 283 | if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" }; |
| 258 | 284 | |
| 259 | const themes: ThemeDefinition[] = []; | |
| 260 | for (const value of parsed) { | |
| 261 | const theme = parseStoredTheme(value); | |
| 262 | if (theme && !themes.some((existing) => existing.id === theme.id)) { | |
| 263 | themes.push(theme); | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | return { status: "ready", storedThemes: parsed, themes }; | |
| 285 | return { status: "ready", storedThemes: parsed, themes: parseStoredThemes(parsed) }; | |
| 268 | 286 | } |
| 269 | 287 | |
| 270 | 288 | function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| ... | ... | @@ -288,6 +306,14 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { |
| 288 | 306 | return snapshot.status === "ready" ? snapshot.themes : []; |
| 289 | 307 | } |
| 290 | 308 | |
| 309 | export function getStoredCustomThemeCollection( | |
| 310 | collectionId: string, | |
| 311 | ): ReadonlyArray<ThemeDefinition> { | |
| 312 | return readWritableCustomThemeLibrary().themes.filter( | |
| 313 | (theme) => theme.collection?.id === collectionId, | |
| 314 | ); | |
| 315 | } | |
| 316 | ||
| 291 | 317 | export function subscribeToCustomThemes(listener: () => void): () => void { |
| 292 | 318 | customThemeListeners.add(listener); |
| 293 | 319 | if (typeof window === "undefined") { |
| ... | ... | @@ -1598,8 +1624,9 @@ function saveCustomThemes( |
| 1598 | 1624 | notifyCustomThemeListeners(); |
| 1599 | 1625 | } |
| 1600 | 1626 | |
| 1601 | function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> { | |
| 1602 | const snapshot = getCustomThemeLibrarySnapshot(); | |
| 1627 | function requireWritableCustomThemeLibrary( | |
| 1628 | snapshot: CustomThemeLibrarySnapshot, | |
| 1629 | ): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> { | |
| 1603 | 1630 | if (snapshot.status === "unavailable") { |
| 1604 | 1631 | throw new ThemeLibraryStorageError({ |
| 1605 | 1632 | storageKey: CUSTOM_THEMES_STORAGE_KEY, |
| ... | ... | @@ -1611,10 +1638,29 @@ function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { |
| 1611 | 1638 | return snapshot; |
| 1612 | 1639 | } |
| 1613 | 1640 | |
| 1641 | function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> { | |
| 1642 | return requireWritableCustomThemeLibrary(getCustomThemeLibrarySnapshot()); | |
| 1643 | } | |
| 1644 | ||
| 1645 | function readWritableCustomThemeLibrary(): Extract< | |
| 1646 | CustomThemeLibrarySnapshot, | |
| 1647 | { status: "ready" } | |
| 1648 | > { | |
| 1649 | return requireWritableCustomThemeLibrary(readCustomThemeLibrarySnapshot()); | |
| 1650 | } | |
| 1651 | ||
| 1614 | 1652 | function storedThemeHasId(storedTheme: unknown, themeId: string): boolean { |
| 1615 | 1653 | return isRecord(storedTheme) && storedTheme.id === themeId; |
| 1616 | 1654 | } |
| 1617 | 1655 | |
| 1656 | function storedThemeHasCollectionId(storedTheme: unknown, collectionId: string): boolean { | |
| 1657 | return ( | |
| 1658 | isRecord(storedTheme) && | |
| 1659 | isRecord(storedTheme.collection) && | |
| 1660 | storedTheme.collection.id === collectionId | |
| 1661 | ); | |
| 1662 | } | |
| 1663 | ||
| 1618 | 1664 | export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1619 | 1665 | if (RESERVED_THEME_IDS.has(theme.id)) { |
| 1620 | 1666 | throw new Error(`The theme id "${theme.id}" is reserved.`); |
| ... | ... | @@ -1662,12 +1708,80 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1662 | 1708 | return canonicalTheme; |
| 1663 | 1709 | } |
| 1664 | 1710 | |
| 1711 | export function replaceCustomThemeCollection( | |
| 1712 | collectionId: string, | |
| 1713 | themes: ReadonlyArray<ThemeDefinition>, | |
| 1714 | options?: { expectedCollection?: ReadonlyArray<ThemeDefinition> }, | |
| 1715 | ): ReadonlyArray<ThemeDefinition> { | |
| 1716 | if (themes.length === 0) throw new Error("A theme collection cannot be empty."); | |
| 1717 | ||
| 1718 | const validated = themes.map((theme) => parseStoredTheme(theme)); | |
| 1719 | if ( | |
| 1720 | validated.some((theme) => theme === null || theme.collection?.id !== collectionId) || | |
| 1721 | new Set(validated.map((theme) => theme?.id)).size !== validated.length | |
| 1722 | ) { | |
| 1723 | throw new Error("That theme collection is invalid."); | |
| 1724 | } | |
| 1725 | const replacement = validated as ThemeDefinition[]; | |
| 1726 | const library = readWritableCustomThemeLibrary(); | |
| 1727 | const current = library.themes; | |
| 1728 | const currentCollection = current.filter((theme) => theme.collection?.id === collectionId); | |
| 1729 | if ( | |
| 1730 | options?.expectedCollection && | |
| 1731 | JSON.stringify(currentCollection) !== JSON.stringify(options.expectedCollection) | |
| 1732 | ) { | |
| 1733 | throw new Error("Your installed themes changed while this package was downloading. Try again."); | |
| 1734 | } | |
| 1735 | const occupiedIds = new Set(BUILT_IN_THEME_DEFINITIONS.map((theme) => theme.id)); | |
| 1736 | for (const storedTheme of library.storedThemes) { | |
| 1737 | if ( | |
| 1738 | !storedThemeHasCollectionId(storedTheme, collectionId) && | |
| 1739 | isRecord(storedTheme) && | |
| 1740 | typeof storedTheme.id === "string" | |
| 1741 | ) { | |
| 1742 | occupiedIds.add(storedTheme.id); | |
| 1743 | } | |
| 1744 | } | |
| 1745 | const conflictingTheme = replacement.find( | |
| 1746 | (theme) => RESERVED_THEME_IDS.has(theme.id) || occupiedIds.has(theme.id), | |
| 1747 | ); | |
| 1748 | if (conflictingTheme) { | |
| 1749 | throw new Error(`A theme named "${conflictingTheme.label}" is already installed.`); | |
| 1750 | } | |
| 1751 | ||
| 1752 | const nextStoredThemes: unknown[] = []; | |
| 1753 | let insertedReplacement = false; | |
| 1754 | for (const storedTheme of library.storedThemes) { | |
| 1755 | if (!storedThemeHasCollectionId(storedTheme, collectionId)) { | |
| 1756 | nextStoredThemes.push(storedTheme); | |
| 1757 | } else if (!insertedReplacement) { | |
| 1758 | nextStoredThemes.push(...replacement); | |
| 1759 | insertedReplacement = true; | |
| 1760 | } | |
| 1761 | } | |
| 1762 | if (!insertedReplacement) nextStoredThemes.push(...replacement); | |
| 1763 | ||
| 1764 | saveCustomThemes(nextStoredThemes, parseStoredThemes(nextStoredThemes)); | |
| 1765 | return replacement; | |
| 1766 | } | |
| 1767 | ||
| 1665 | 1768 | export function removeCustomTheme(themeId: string): void { |
| 1769 | removeCustomThemes([themeId]); | |
| 1770 | } | |
| 1771 | ||
| 1772 | export function removeCustomThemes(themeIds: ReadonlyArray<string>): void { | |
| 1773 | const removedIds = new Set(themeIds); | |
| 1774 | if (removedIds.size === 0) return; | |
| 1666 | 1775 | const library = getWritableCustomThemeLibrary(); |
| 1667 | const nextThemes = library.themes.filter((theme) => theme.id !== themeId); | |
| 1776 | const nextThemes = library.themes.filter((theme) => !removedIds.has(theme.id)); | |
| 1668 | 1777 | if (nextThemes.length === library.themes.length) return; |
| 1669 | 1778 | saveCustomThemes( |
| 1670 | library.storedThemes.filter((storedTheme) => !storedThemeHasId(storedTheme, themeId)), | |
| 1779 | library.storedThemes.filter( | |
| 1780 | (storedTheme) => | |
| 1781 | !isRecord(storedTheme) || | |
| 1782 | typeof storedTheme.id !== "string" || | |
| 1783 | !removedIds.has(storedTheme.id), | |
| 1784 | ), | |
| 1671 | 1785 | nextThemes, |
| 1672 | 1786 | ); |
| 1673 | 1787 | } |
| ... | ... | @@ -1720,6 +1834,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition { |
| 1720 | 1834 | } |
| 1721 | 1835 | |
| 1722 | 1836 | const overrides = parseThemeColorOverrides(rawColors); |
| 1837 | const collection = parseThemeCollection(value.collection); | |
| 1838 | if (value.collection !== undefined && !collection) { | |
| 1839 | throw new Error("Theme collections need a valid id and label."); | |
| 1840 | } | |
| 1723 | 1841 | |
| 1724 | 1842 | const fallback = getDefaultThemeColors(appearance); |
| 1725 | 1843 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; |
| ... | ... | @@ -1746,6 +1864,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { |
| 1746 | 1864 | appearance, |
| 1747 | 1865 | colors: { ...fallback, ...overrides }, |
| 1748 | 1866 | ...(Object.keys(variants).length > 0 ? { variants } : {}), |
| 1867 | ...(collection ? { collection } : {}), | |
| 1749 | 1868 | ...(value.managed === true ? { managed: true } : {}), |
| 1750 | 1869 | }; |
| 1751 | 1870 | } |
| ... | ... | @@ -1759,6 +1878,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { |
| 1759 | 1878 | appearance: canonicalTheme.appearance, |
| 1760 | 1879 | colors: canonicalTheme.colors, |
| 1761 | 1880 | ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}), |
| 1881 | ...(canonicalTheme.collection ? { collection: canonicalTheme.collection } : {}), | |
| 1762 | 1882 | ...(canonicalTheme.managed ? { managed: true } : {}), |
| 1763 | 1883 | }; |
| 1764 | 1884 | return `${JSON.stringify(file, null, 2)}\n`; |
apps/web/src/vscodeThemeImport.ts+4| ... | ... | @@ -337,6 +337,9 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { |
| 337 | 337 | */ |
| 338 | 338 | export function pairVsCodeThemes( |
| 339 | 339 | themes: ReadonlyArray<ThemeDefinition>, |
| 340 | options?: { | |
| 341 | pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string; | |
| 342 | }, | |
| 340 | 343 | ): ReadonlyArray<ThemeDefinition> { |
| 341 | 344 | const stripAppearance = (label: string) => |
| 342 | 345 | label |
| ... | ... | @@ -372,6 +375,7 @@ export function pairVsCodeThemes( |
| 372 | 375 | order: group.order, |
| 373 | 376 | theme: parseThemeFile({ |
| 374 | 377 | version: THEME_FILE_VERSION, |
| 378 | ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}), | |
| 375 | 379 | name: key, |
| 376 | 380 | appearance: "light", |
| 377 | 381 | colors: group.light[0]!.colors, |
pnpm-lock.yaml+9| ... | ... | @@ -546,6 +546,9 @@ importers: |
| 546 | 546 | '@lexical/react': |
| 547 | 547 | specifier: ^0.41.0 |
| 548 | 548 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) |
| 549 | '@noble/hashes': | |
| 550 | specifier: 'catalog:' | |
| 551 | version: 1.8.0 | |
| 549 | 552 | '@pierre/diffs': |
| 550 | 553 | specifier: 'catalog:' |
| 551 | 554 | version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| ... | ... | @@ -579,6 +582,12 @@ importers: |
| 579 | 582 | jose: |
| 580 | 583 | specifier: 'catalog:' |
| 581 | 584 | version: 6.2.2 |
| 585 | jsonc-parser: | |
| 586 | specifier: 3.3.1 | |
| 587 | version: 3.3.1 | |
| 588 | jszip: | |
| 589 | specifier: 3.10.1 | |
| 590 | version: 3.10.1 | |
| 582 | 591 | lexical: |
| 583 | 592 | specifier: ^0.41.0 |
| 584 | 593 | version: 0.41.0 |