| 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,6 +23,7 @@ |
| 23 | "@formkit/auto-animate": "^0.9.0", | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | "@legendapp/list": "catalog:", | 24 | "@legendapp/list": "catalog:", |
| 25 | "@lexical/react": "^0.41.0", | 25 | "@lexical/react": "^0.41.0", |
| 26 | "@noble/hashes": "catalog:", | ||
| 26 | "@pierre/diffs": "catalog:", | 27 | "@pierre/diffs": "catalog:", |
| 27 | "@pierre/trees": "1.0.0-beta.4", | 28 | "@pierre/trees": "1.0.0-beta.4", |
| 28 | "@t3tools/client-runtime": "workspace:*", | 29 | "@t3tools/client-runtime": "workspace:*", |
| ... | @@ -34,6 +35,8 @@ | ... | @@ -34,6 +35,8 @@ |
| 34 | "culori": "^4.0.2", | 35 | "culori": "^4.0.2", |
| 35 | "effect": "catalog:", | 36 | "effect": "catalog:", |
| 36 | "jose": "catalog:", | 37 | "jose": "catalog:", |
| 38 | "jsonc-parser": "3.3.1", | ||
| 39 | "jszip": "3.10.1", | ||
| 37 | "lexical": "^0.41.0", | 40 | "lexical": "^0.41.0", |
| 38 | "lucide-react": "^0.564.0", | 41 | "lucide-react": "^0.564.0", |
| 39 | "react": "19.2.6", | 42 | "react": "19.2.6", |
apps/web/src/components/settings/ThemeEditorPanel.tsx+14-23| ... | @@ -610,7 +610,7 @@ export function ThemeEditorPanel({ | ... | @@ -610,7 +610,7 @@ export function ThemeEditorPanel({ |
| 610 | [activeAppearance, editingTheme, selectedRole], | 610 | [activeAppearance, editingTheme, selectedRole], |
| 611 | ); | 611 | ); |
| 612 | 612 | ||
| 613 | const handleSubmit = useCallback(() => { | 613 | const handleSubmit = () => { |
| 614 | if (!name.trim()) { | 614 | if (!name.trim()) { |
| 615 | setError("Name your theme first."); | 615 | setError("Name your theme first."); |
| 616 | return; | 616 | return; |
| ... | @@ -645,8 +645,8 @@ export function ThemeEditorPanel({ | ... | @@ -645,8 +645,8 @@ export function ThemeEditorPanel({ |
| 645 | return; | 645 | return; |
| 646 | } | 646 | } |
| 647 | mergedAppearance = editedModes[0] ?? null; | 647 | mergedAppearance = editedModes[0] ?? null; |
| 648 | savedTheme = updateCustomTheme( | 648 | savedTheme = updateCustomTheme({ |
| 649 | parseThemeFile({ | 649 | ...parseThemeFile({ |
| 650 | version: THEME_FILE_VERSION, | 650 | version: THEME_FILE_VERSION, |
| 651 | id: mergeTarget.id, | 651 | id: mergeTarget.id, |
| 652 | name: mergeTarget.label, | 652 | name: mergeTarget.label, |
| ... | @@ -658,7 +658,8 @@ export function ThemeEditorPanel({ | ... | @@ -658,7 +658,8 @@ export function ThemeEditorPanel({ |
| 658 | }, | 658 | }, |
| 659 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), | 659 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 660 | }), | 660 | }), |
| 661 | ); | 661 | ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), |
| 662 | }); | ||
| 662 | retiredTheme = editingTheme; | 663 | retiredTheme = editingTheme; |
| 663 | try { | 664 | try { |
| 664 | removeCustomTheme(editingTheme.id); | 665 | removeCustomTheme(editingTheme.id); |
| ... | @@ -676,8 +677,8 @@ export function ThemeEditorPanel({ | ... | @@ -676,8 +677,8 @@ export function ThemeEditorPanel({ |
| 676 | } else if (editingTheme) { | 677 | } else if (editingTheme) { |
| 677 | const baseAppearance = editingTheme.appearance; | 678 | const baseAppearance = editingTheme.appearance; |
| 678 | const variantAppearance = baseAppearance === "light" ? "dark" : "light"; | 679 | const variantAppearance = baseAppearance === "light" ? "dark" : "light"; |
| 679 | savedTheme = updateCustomTheme( | 680 | savedTheme = updateCustomTheme({ |
| 680 | parseThemeFile({ | 681 | ...parseThemeFile({ |
| 681 | version: THEME_FILE_VERSION, | 682 | version: THEME_FILE_VERSION, |
| 682 | id: editingTheme.id, | 683 | id: editingTheme.id, |
| 683 | name, | 684 | name, |
| ... | @@ -688,7 +689,8 @@ export function ThemeEditorPanel({ | ... | @@ -688,7 +689,8 @@ export function ThemeEditorPanel({ |
| 688 | : {}), | 689 | : {}), |
| 689 | ...(isAdvanced ? {} : { managed: true }), | 690 | ...(isAdvanced ? {} : { managed: true }), |
| 690 | }), | 691 | }), |
| 691 | ); | 692 | ...(editingTheme.collection ? { collection: editingTheme.collection } : {}), |
| 693 | }); | ||
| 692 | } else if (mergeTarget) { | 694 | } else if (mergeTarget) { |
| 693 | if (takenAppearances.includes(activeAppearance)) { | 695 | if (takenAppearances.includes(activeAppearance)) { |
| 694 | setError( | 696 | setError( |
| ... | @@ -701,8 +703,8 @@ export function ThemeEditorPanel({ | ... | @@ -701,8 +703,8 @@ export function ThemeEditorPanel({ |
| 701 | // survives when every palette in the theme came from the guided | 703 | // survives when every palette in the theme came from the guided |
| 702 | // editor. | 704 | // editor. |
| 703 | mergedAppearance = activeAppearance; | 705 | mergedAppearance = activeAppearance; |
| 704 | savedTheme = updateCustomTheme( | 706 | savedTheme = updateCustomTheme({ |
| 705 | parseThemeFile({ | 707 | ...parseThemeFile({ |
| 706 | version: THEME_FILE_VERSION, | 708 | version: THEME_FILE_VERSION, |
| 707 | id: mergeTarget.id, | 709 | id: mergeTarget.id, |
| 708 | name: mergeTarget.label, | 710 | name: mergeTarget.label, |
| ... | @@ -714,7 +716,8 @@ export function ThemeEditorPanel({ | ... | @@ -714,7 +716,8 @@ export function ThemeEditorPanel({ |
| 714 | }, | 716 | }, |
| 715 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), | 717 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 716 | }), | 718 | }), |
| 717 | ); | 719 | ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), |
| 720 | }); | ||
| 718 | } else { | 721 | } else { |
| 719 | savedTheme = installCustomTheme( | 722 | savedTheme = installCustomTheme( |
| 720 | parseThemeFile({ | 723 | parseThemeFile({ |
| ... | @@ -762,19 +765,7 @@ export function ThemeEditorPanel({ | ... | @@ -762,19 +765,7 @@ export function ThemeEditorPanel({ |
| 762 | : "Could not create the theme.", | 765 | : "Could not create the theme.", |
| 763 | ); | 766 | ); |
| 764 | } | 767 | } |
| 765 | }, [ | 768 | }; |
| 766 | activeAppearance, | ||
| 767 | colorsByAppearance, | ||
| 768 | editingTheme, | ||
| 769 | isAdvanced, | ||
| 770 | isEditing, | ||
| 771 | mergeTarget, | ||
| 772 | name, | ||
| 773 | onOpenChange, | ||
| 774 | onSaved, | ||
| 775 | simpleColorsDirtyByAppearance, | ||
| 776 | takenAppearances, | ||
| 777 | ]); | ||
| 778 | 769 | ||
| 779 | const renderNameField = () => ( | 770 | const renderNameField = () => ( |
| 780 | <label className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3"> | 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,6 +28,7 @@ import { |
| 28 | DialogPopup, | 28 | DialogPopup, |
| 29 | DialogTitle, | 29 | DialogTitle, |
| 30 | } from "../ui/dialog"; | 30 | } from "../ui/dialog"; |
| 31 | import { ThemeSearchSection } from "./ThemeSearchSection"; | ||
| 31 | 32 | ||
| 32 | /** | 33 | /** |
| 33 | * A full theme export is a few KB, so anything past this is not a theme file. | 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,9 +361,16 @@ export function ThemeImportDialog({ |
| 360 | : null; | 361 | : null; |
| 361 | for (const theme of conflicts) { | 362 | for (const theme of conflicts) { |
| 362 | try { | 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 | resolved.push( | 371 | resolved.push( |
| 364 | mode === "update" | 372 | mode === "update" |
| 365 | ? updateCustomTheme(theme) | 373 | ? updateCustomTheme(themeToUpdate) |
| 366 | : installCustomTheme(versionedCopy(theme, preferredName)), | 374 | : installCustomTheme(versionedCopy(theme, preferredName)), |
| 367 | ); | 375 | ); |
| 368 | } catch (cause) { | 376 | } catch (cause) { |
| ... | @@ -426,7 +434,23 @@ export function ThemeImportDialog({ | ... | @@ -426,7 +434,23 @@ export function ThemeImportDialog({ |
| 426 | <DialogHeader> | 434 | <DialogHeader> |
| 427 | <DialogTitle>Add a theme</DialogTitle> | 435 | <DialogTitle>Add a theme</DialogTitle> |
| 428 | </DialogHeader> | 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 | const dropHandlers = { | 455 | const dropHandlers = { |
| 432 | onDragEnter: (event: DragEvent<HTMLDivElement>) => { | 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,17 +1,20 @@ |
| 1 | import { | 1 | import { |
| 2 | CheckIcon, | ||
| 2 | CopyIcon, | 3 | CopyIcon, |
| 3 | DownloadIcon, | 4 | DownloadIcon, |
| 5 | MoonIcon, | ||
| 4 | PenLineIcon, | 6 | PenLineIcon, |
| 5 | PlusIcon, | 7 | PlusIcon, |
| 8 | SunIcon, | ||
| 6 | Trash2Icon, | 9 | Trash2Icon, |
| 7 | UploadIcon, | 10 | UploadIcon, |
| 8 | } from "lucide-react"; | 11 | } from "lucide-react"; |
| 9 | import { useCallback, useEffect, useRef, useState } from "react"; | 12 | import { useCallback, useEffect, useState, type ReactElement } from "react"; |
| 10 | import { cn } from "../../lib/utils"; | 13 | import { cn } from "../../lib/utils"; |
| 11 | import { | 14 | import { |
| 12 | getThemeDefinition, | 15 | getThemeDefinition, |
| 13 | getThemeModes, | 16 | getThemeModes, |
| 14 | removeCustomTheme, | 17 | removeCustomThemes, |
| 15 | serializeThemeFile, | 18 | serializeThemeFile, |
| 16 | type ThemeAppearance, | 19 | type ThemeAppearance, |
| 17 | type ThemeDefinition, | 20 | type ThemeDefinition, |
| ... | @@ -41,6 +44,7 @@ import { | ... | @@ -41,6 +44,7 @@ import { |
| 41 | getThemeCardDefinition, | 44 | getThemeCardDefinition, |
| 42 | previewColorsOf, | 45 | previewColorsOf, |
| 43 | ThemePreviewCircles, | 46 | ThemePreviewCircles, |
| 47 | ThemePreviewCircle, | ||
| 44 | type ThemeCardDefinition, | 48 | type ThemeCardDefinition, |
| 45 | type ThemeMode, | 49 | type ThemeMode, |
| 46 | } from "./ThemePreviewCircles"; | 50 | } from "./ThemePreviewCircles"; |
| ... | @@ -54,6 +58,21 @@ const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [ | ... | @@ -54,6 +58,21 @@ const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [ |
| 54 | IRIS_THEME, | 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 | function downloadThemeFile(filename: string, contents: string): void { | 76 | function downloadThemeFile(filename: string, contents: string): void { |
| 58 | const url = URL.createObjectURL(new Blob([contents], { type: "application/json" })); | 77 | const url = URL.createObjectURL(new Blob([contents], { type: "application/json" })); |
| 59 | const anchor = document.createElement("a"); | 78 | const anchor = document.createElement("a"); |
| ... | @@ -65,6 +84,15 @@ function downloadThemeFile(filename: string, contents: string): void { | ... | @@ -65,6 +84,15 @@ function downloadThemeFile(filename: string, contents: string): void { |
| 65 | setTimeout(() => URL.revokeObjectURL(url), 30_000); | 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 | function ThemeLibraryCard({ | 96 | function ThemeLibraryCard({ |
| 69 | theme, | 97 | theme, |
| 70 | isActive, | 98 | isActive, |
| ... | @@ -75,6 +103,7 @@ function ThemeLibraryCard({ | ... | @@ -75,6 +103,7 @@ function ThemeLibraryCard({ |
| 75 | onDuplicate, | 103 | onDuplicate, |
| 76 | onDownload, | 104 | onDownload, |
| 77 | onRemove, | 105 | onRemove, |
| 106 | variantNavigation, | ||
| 78 | }: { | 107 | }: { |
| 79 | theme: ThemeCardDefinition; | 108 | theme: ThemeCardDefinition; |
| 80 | isActive: boolean; | 109 | isActive: boolean; |
| ... | @@ -85,10 +114,33 @@ function ThemeLibraryCard({ | ... | @@ -85,10 +114,33 @@ function ThemeLibraryCard({ |
| 85 | onDuplicate?: () => void; | 114 | onDuplicate?: () => void; |
| 86 | onDownload?: () => void; | 115 | onDownload?: () => void; |
| 87 | onRemove?: () => void; | 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 | // A one-appearance theme can only take its own side of the mix, so the card | 128 | // A one-appearance theme can only take its own side of the mix, so the card |
| 90 | // tooltip promises exactly what clicking it does. | 129 | // tooltip promises exactly what clicking it does. |
| 91 | const cardModes = theme.previews.map((preview) => preview.mode); | 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 | return ( | 144 | return ( |
| 93 | // The card surface stays a plain div (buttons cannot nest inside a button | 145 | // The card surface stays a plain div (buttons cannot nest inside a button |
| 94 | // role); the title button and mode circles carry the accessible actions, | 146 | // role); the title button and mode circles carry the accessible actions, |
| ... | @@ -105,17 +157,147 @@ function ThemeLibraryCard({ | ... | @@ -105,17 +157,147 @@ function ThemeLibraryCard({ |
| 105 | onClick={onUse} | 157 | onClick={onUse} |
| 106 | style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined} | 158 | style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined} |
| 107 | > | 159 | > |
| 108 | <ThemePreviewCircles | 160 | <div className="relative"> |
| 109 | label={theme.label} | 161 | {variantNavigation ? ( |
| 110 | activeModes={activeModes} | 162 | <div |
| 111 | onSelectMode={onUseMode} | 163 | aria-label="Light and dark theme variants" |
| 112 | previews={theme.previews} | 164 | className="relative h-20" |
| 113 | /> | 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 | <div className="flex items-center gap-2 px-3 pb-3 pt-2"> | 296 | <div className="flex items-center gap-2 px-3 pb-3 pt-2"> |
| 115 | <div className="min-w-0 flex-1"> | 297 | <div className="min-w-0 flex-1"> |
| 116 | <div className="flex items-center gap-1.5"> | 298 | <div className="flex items-center gap-1.5"> |
| 117 | <button | 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 | aria-pressed={isActive} | 301 | aria-pressed={isActive} |
| 120 | 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" | 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 | type="button" | 303 | type="button" |
| ... | @@ -124,7 +306,7 @@ function ThemeLibraryCard({ | ... | @@ -124,7 +306,7 @@ function ThemeLibraryCard({ |
| 124 | onUse(); | 306 | onUse(); |
| 125 | }} | 307 | }} |
| 126 | > | 308 | > |
| 127 | {theme.label} | 309 | {variantNavigation?.collectionLabel ?? theme.label} |
| 128 | </button> | 310 | </button> |
| 129 | </div> | 311 | </div> |
| 130 | </div> | 312 | </div> |
| ... | @@ -195,7 +377,11 @@ function ThemeLibraryCard({ | ... | @@ -195,7 +377,11 @@ function ThemeLibraryCard({ |
| 195 | <TooltipTrigger | 377 | <TooltipTrigger |
| 196 | render={ | 378 | render={ |
| 197 | <Button | 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 | size="icon-xs" | 385 | size="icon-xs" |
| 200 | variant="ghost" | 386 | variant="ghost" |
| 201 | className="text-muted-foreground hover:text-destructive" | 387 | className="text-muted-foreground hover:text-destructive" |
| ... | @@ -208,7 +394,9 @@ function ThemeLibraryCard({ | ... | @@ -208,7 +394,9 @@ function ThemeLibraryCard({ |
| 208 | </Button> | 394 | </Button> |
| 209 | } | 395 | } |
| 210 | /> | 396 | /> |
| 211 | <TooltipPopup>Remove theme</TooltipPopup> | 397 | <TooltipPopup> |
| 398 | {variantNavigation ? "Remove themes" : "Remove theme"} | ||
| 399 | </TooltipPopup> | ||
| 212 | </Tooltip> | 400 | </Tooltip> |
| 213 | ) : null} | 401 | ) : null} |
| 214 | </div> | 402 | </div> |
| ... | @@ -218,12 +406,97 @@ function ThemeLibraryCard({ | ... | @@ -218,12 +406,97 @@ function ThemeLibraryCard({ |
| 218 | } | 406 | } |
| 219 | /> | 407 | /> |
| 220 | <TooltipPopup> | 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 | </TooltipPopup> | 414 | </TooltipPopup> |
| 223 | </Tooltip> | 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 | export function ThemeLibrary({ | 500 | export function ThemeLibrary({ |
| 228 | theme, | 501 | theme, |
| 229 | setTheme, | 502 | setTheme, |
| ... | @@ -250,14 +523,20 @@ export function ThemeLibrary({ | ... | @@ -250,14 +523,20 @@ export function ThemeLibrary({ |
| 250 | setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean; | 523 | setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean; |
| 251 | }) { | 524 | }) { |
| 252 | const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); | 525 | const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); |
| 253 | const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null); | 526 | const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ |
| 254 | // Keep the last removal target so the dialog title stays populated while the | 527 | theme: ThemeDefinition; |
| 255 | // close animation plays after confirming. | 528 | collectionThemes: ReadonlyArray<ThemeDefinition>; |
| 256 | const lastThemeToRemoveRef = useRef<ThemeDefinition | null>(null); | 529 | } | null>(null); |
| 257 | useEffect(() => { | 530 | // Keep the target after closing so the dialog text remains populated during |
| 258 | if (themeToRemove) lastThemeToRemoveRef.current = themeToRemove; | 531 | // its exit animation. The next trash action replaces it before reopening. |
| 259 | }, [themeToRemove]); | 532 | const [isThemeRemovalOpen, setIsThemeRemovalOpen] = useState(false); |
| 260 | const removeDialogTheme = themeToRemove ?? lastThemeToRemoveRef.current; | 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 | const notifyThemeSaveFailure = useCallback(() => { | 541 | const notifyThemeSaveFailure = useCallback(() => { |
| 263 | toastManager.add( | 542 | toastManager.add( |
| ... | @@ -288,37 +567,43 @@ export function ThemeLibrary({ | ... | @@ -288,37 +567,43 @@ export function ThemeLibrary({ |
| 288 | [notifyThemeSaveFailure, setTheme], | 567 | [notifyThemeSaveFailure, setTheme], |
| 289 | ); | 568 | ); |
| 290 | 569 | ||
| 291 | const handleRemoveTheme = useCallback((customTheme: ThemeDefinition) => { | 570 | const handleRemoveTheme = useCallback( |
| 292 | setThemeToRemove(customTheme); | 571 | (customTheme: ThemeDefinition, collectionThemes: ReadonlyArray<ThemeDefinition>) => { |
| 293 | }, []); | 572 | setThemeRemovalTarget({ theme: customTheme, collectionThemes }); |
| 573 | setThemeIdsToRemove(collectionThemes.length > 1 ? [] : [customTheme.id]); | ||
| 574 | setIsThemeRemovalOpen(true); | ||
| 575 | }, | ||
| 576 | [], | ||
| 577 | ); | ||
| 294 | 578 | ||
| 295 | const handleConfirmRemoveTheme = useCallback(() => { | 579 | const handleConfirmRemoveTheme = useCallback(() => { |
| 296 | if (!themeToRemove) return; | 580 | if (!themeRemovalTarget) return; |
| 297 | const removesBase = getThemeDefinition(theme)?.id === themeToRemove.id; | 581 | const removedIds = new Set(themeIdsToRemove); |
| 298 | // Keep the theme installed if we cannot move the selection off it; the | 582 | if (removedIds.size === 0) return; |
| 299 | // dialog stays open so the user can retry or cancel. | 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 | if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) { | 586 | if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) { |
| 301 | return; | 587 | return; |
| 302 | } | 588 | } |
| 303 | for (const appearance of ["light", "dark"] as const) { | 589 | for (const appearance of ["light", "dark"] as const) { |
| 304 | const half = themeHalves?.[appearance]; | 590 | const half = themeHalves?.[appearance]; |
| 305 | if (half === undefined) continue; | 591 | if (half === undefined) continue; |
| 306 | // Writing a base preference clears the whole mix, so halves that name a | 592 | // Writing a base preference clears the whole mix, so halves that name |
| 307 | // surviving theme are written back; halves on the removed theme fall | 593 | // a surviving theme are written back; removed halves fall back to base. |
| 308 | // back to the base. | 594 | const next = half && removedIds.has(half) ? null : removesBase ? half : undefined; |
| 309 | const next = half === themeToRemove.id ? null : removesBase ? half : undefined; | ||
| 310 | if (next !== undefined && !setThemeHalf(appearance, next)) { | 595 | if (next !== undefined && !setThemeHalf(appearance, next)) { |
| 311 | notifyThemeRemovalFailure(); | 596 | notifyThemeRemovalFailure(); |
| 312 | return; | 597 | return; |
| 313 | } | 598 | } |
| 314 | } | 599 | } |
| 315 | try { | 600 | try { |
| 316 | removeCustomTheme(themeToRemove.id); | 601 | removeCustomThemes([...removedIds]); |
| 317 | } catch { | 602 | } catch { |
| 318 | notifyThemeRemovalFailure(); | 603 | notifyThemeRemovalFailure(); |
| 319 | return; | 604 | return; |
| 320 | } | 605 | } |
| 321 | setThemeToRemove(null); | 606 | setIsThemeRemovalOpen(false); |
| 322 | }, [ | 607 | }, [ |
| 323 | appearanceMode, | 608 | appearanceMode, |
| 324 | notifyThemeRemovalFailure, | 609 | notifyThemeRemovalFailure, |
| ... | @@ -326,7 +611,8 @@ export function ThemeLibrary({ | ... | @@ -326,7 +611,8 @@ export function ThemeLibrary({ |
| 326 | setThemeHalf, | 611 | setThemeHalf, |
| 327 | theme, | 612 | theme, |
| 328 | themeHalves, | 613 | themeHalves, |
| 329 | themeToRemove, | 614 | themeIdsToRemove, |
| 615 | themeRemovalTarget, | ||
| 330 | ]); | 616 | ]); |
| 331 | 617 | ||
| 332 | // ----- Automatic-mode mixing ------------------------------------------- | 618 | // ----- Automatic-mode mixing ------------------------------------------- |
| ... | @@ -460,6 +746,20 @@ export function ThemeLibrary({ | ... | @@ -460,6 +746,20 @@ export function ThemeLibrary({ |
| 460 | </div> | 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 | const renderPairGrid = () => ( | 763 | const renderPairGrid = () => ( |
| 464 | // One shared provider so every tooltip in the grid hands off instantly to | 764 | // One shared provider so every tooltip in the grid hands off instantly to |
| 465 | // the next hovered trigger instead of stacking on top of it. The card | 765 | // the next hovered trigger instead of stacking on top of it. The card |
| ... | @@ -509,43 +809,39 @@ export function ThemeLibrary({ | ... | @@ -509,43 +809,39 @@ export function ThemeLibrary({ |
| 509 | /> | 809 | /> |
| 510 | ); | 810 | ); |
| 511 | })} | 811 | })} |
| 512 | {customThemes.map((customTheme) => { | 812 | {customThemeCollections.map(([collectionId, themes]) => ( |
| 513 | const card = getThemeCardDefinition(customTheme); | 813 | <CustomThemeCollectionCard |
| 514 | return ( | 814 | activeModesFor={pickedModesFor} |
| 515 | <ThemeLibraryCard | 815 | key={collectionId} |
| 516 | activeModes={pickedModesFor(customTheme.id)} | 816 | onDownload={(customTheme) => |
| 517 | isActive={false} | 817 | downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme)) |
| 518 | key={customTheme.id} | 818 | } |
| 519 | onDuplicate={() => | 819 | onDuplicate={(customTheme) => |
| 520 | openThemeEditor({ | 820 | openThemeEditor({ |
| 521 | editingThemeId: null, | 821 | editingThemeId: null, |
| 522 | seedThemeId: customTheme.id, | 822 | seedThemeId: customTheme.id, |
| 523 | seedName: `${customTheme.label} copy`, | 823 | seedName: `${customTheme.label} copy`, |
| 524 | initialAppearance, | 824 | initialAppearance, |
| 525 | }) | 825 | }) |
| 526 | } | 826 | } |
| 527 | onEdit={() => | 827 | onEdit={(customTheme) => |
| 528 | openThemeEditor({ | 828 | openThemeEditor({ |
| 529 | editingThemeId: customTheme.id, | 829 | editingThemeId: customTheme.id, |
| 530 | seedThemeId: null, | 830 | seedThemeId: null, |
| 531 | seedName: null, | 831 | seedName: null, |
| 532 | initialAppearance, | 832 | initialAppearance, |
| 533 | }) | 833 | }) |
| 534 | } | 834 | } |
| 535 | onDownload={() => | 835 | onRemove={(customTheme) => handleRemoveTheme(customTheme, themes)} |
| 536 | downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme)) | 836 | onUse={(customTheme) => { |
| 537 | } | 837 | const modes = getThemeModes(customTheme); |
| 538 | onRemove={() => handleRemoveTheme(customTheme)} | 838 | if (modes.length === 1) assignHalf(modes[0]!, customTheme.id); |
| 539 | onUse={() => { | 839 | else persistTheme(customTheme.id); |
| 540 | const modes = getThemeModes(customTheme); | 840 | }} |
| 541 | if (modes.length === 1) assignHalf(modes[0]!, customTheme.id); | 841 | onUseMode={(customTheme, mode) => handlePairPick(customTheme.id)(mode)} |
| 542 | else persistTheme(customTheme.id); | 842 | themes={themes} |
| 543 | }} | 843 | /> |
| 544 | onUseMode={handlePairPick(customTheme.id)} | 844 | ))} |
| 545 | theme={card} | ||
| 546 | /> | ||
| 547 | ); | ||
| 548 | })} | ||
| 549 | </div> | 845 | </div> |
| 550 | </TooltipProvider> | 846 | </TooltipProvider> |
| 551 | ); | 847 | ); |
| ... | @@ -561,7 +857,7 @@ export function ThemeLibrary({ | ... | @@ -561,7 +857,7 @@ export function ThemeLibrary({ |
| 561 | {renderModeTiles()} | 857 | {renderModeTiles()} |
| 562 | <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4"> | 858 | <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4"> |
| 563 | <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3> | 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 | <Button | 861 | <Button |
| 566 | size="xs" | 862 | size="xs" |
| 567 | variant="outline" | 863 | variant="outline" |
| ... | @@ -586,19 +882,9 @@ export function ThemeLibrary({ | ... | @@ -586,19 +882,9 @@ export function ThemeLibrary({ |
| 586 | {renderPairGrid()} | 882 | {renderPairGrid()} |
| 587 | <ThemeImportDialog | 883 | <ThemeImportDialog |
| 588 | onImportedMany={(importedThemes, { updated }) => { | 884 | onImportedMany={(importedThemes, { updated }) => { |
| 589 | // An updated theme that is showing (as the base or either half) | 885 | // Re-apply after collection updates. The update may remove the |
| 590 | // needs its palette re-applied. | 886 | // selected variant, in which case the theme hook falls back safely. |
| 591 | if ( | 887 | if (updated) refreshTheme(); |
| 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 | } | ||
| 602 | const verb = updated ? "updated" : "added"; | 888 | const verb = updated ? "updated" : "added"; |
| 603 | toastManager.add( | 889 | toastManager.add( |
| 604 | stackedThreadToast({ | 890 | stackedThreadToast({ |
| ... | @@ -639,23 +925,80 @@ export function ThemeLibrary({ | ... | @@ -639,23 +925,80 @@ export function ThemeLibrary({ |
| 639 | onOpenChange={onImportOpenChange} | 925 | onOpenChange={onImportOpenChange} |
| 640 | open={isImportOpen} | 926 | open={isImportOpen} |
| 641 | /> | 927 | /> |
| 642 | <AlertDialog | 928 | <AlertDialog open={isThemeRemovalOpen} onOpenChange={setIsThemeRemovalOpen}> |
| 643 | open={themeToRemove !== null} | ||
| 644 | onOpenChange={(open) => { | ||
| 645 | if (!open) setThemeToRemove(null); | ||
| 646 | }} | ||
| 647 | > | ||
| 648 | <AlertDialogPopup> | 929 | <AlertDialogPopup> |
| 649 | <AlertDialogHeader> | 930 | <AlertDialogHeader> |
| 650 | <AlertDialogTitle>Remove “{removeDialogTheme?.label}”?</AlertDialogTitle> | 931 | <AlertDialogTitle> |
| 932 | {canRemoveCollection | ||
| 933 | ? `Remove themes from “${removeDialogCollectionLabel}”?` | ||
| 934 | : `Remove “${removeDialogTheme?.label}”?`} | ||
| 935 | </AlertDialogTitle> | ||
| 651 | <AlertDialogDescription> | 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 | </AlertDialogDescription> | 940 | </AlertDialogDescription> |
| 654 | </AlertDialogHeader> | 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 | <AlertDialogFooter> | 992 | <AlertDialogFooter> |
| 656 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | 993 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 657 | <Button variant="destructive" onClick={handleConfirmRemoveTheme}> | 994 | <Button |
| 658 | Remove theme | 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 | </Button> | 1002 | </Button> |
| 660 | </AlertDialogFooter> | 1003 | </AlertDialogFooter> |
| 661 | </AlertDialogPopup> | 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,12 +10,15 @@ import { |
| 10 | getThemePreferenceMode, | 10 | getThemePreferenceMode, |
| 11 | isKnownThemePreference, | 11 | isKnownThemePreference, |
| 12 | getCustomThemes, | 12 | getCustomThemes, |
| 13 | getStoredCustomThemeCollection, | ||
| 13 | invalidateCustomThemes, | 14 | invalidateCustomThemes, |
| 14 | installCustomTheme, | 15 | installCustomTheme, |
| 15 | canonicalThemePreference, | 16 | canonicalThemePreference, |
| 16 | parseThemeFile, | 17 | parseThemeFile, |
| 17 | parseThemeHalves, | 18 | parseThemeHalves, |
| 18 | removeCustomTheme, | 19 | removeCustomTheme, |
| 20 | removeCustomThemes, | ||
| 21 | replaceCustomThemeCollection, | ||
| 19 | resolveDesktopTheme, | 22 | resolveDesktopTheme, |
| 20 | resolveThemeAppearance, | 23 | resolveThemeAppearance, |
| 21 | serializeThemeFile, | 24 | serializeThemeFile, |
| ... | @@ -277,6 +280,28 @@ describe("theme files", () => { | ... | @@ -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 | it("keeps sidebar artwork disabled for custom theme files", () => { | 305 | it("keeps sidebar artwork disabled for custom theme files", () => { |
| 281 | const theme = parseThemeFile({ | 306 | const theme = parseThemeFile({ |
| 282 | version: THEME_FILE_VERSION, | 307 | version: THEME_FILE_VERSION, |
| ... | @@ -491,6 +516,178 @@ describe("theme files", () => { | ... | @@ -491,6 +516,178 @@ describe("theme files", () => { |
| 491 | vi.unstubAllGlobals(); | 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 | it("canonicalizes explicit writes without migrating untouched themes", () => { | 691 | it("canonicalizes explicit writes without migrating untouched themes", () => { |
| 495 | const stored = new Map<string, string>(); | 692 | const stored = new Map<string, string>(); |
| 496 | const untouchedTheme = { | 693 | const untouchedTheme = { |
| ... | @@ -551,6 +748,56 @@ describe("theme files", () => { | ... | @@ -551,6 +748,56 @@ describe("theme files", () => { |
| 551 | invalidateCustomThemes(); | 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 | it("writes from the cached raw snapshot without risking a destructive reread", () => { | 801 | it("writes from the cached raw snapshot without risking a destructive reread", () => { |
| 555 | const legacyTheme = { | 802 | const legacyTheme = { |
| 556 | id: "legacy", | 803 | id: "legacy", |
apps/web/src/themePalette.ts+133-13| ... | @@ -92,12 +92,15 @@ export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string | ... | @@ -92,12 +92,15 @@ export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string |
| 92 | export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>; | 92 | export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>; |
| 93 | export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; | 93 | export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; |
| 94 | export type ThemePreferenceMode = ThemeAppearance | "system"; | 94 | export type ThemePreferenceMode = ThemeAppearance | "system"; |
| 95 | export type ThemeCollection = Readonly<{ id: string; label: string }>; | ||
| 95 | export type ThemeDefinition = Readonly<{ | 96 | export type ThemeDefinition = Readonly<{ |
| 96 | id: string; | 97 | id: string; |
| 97 | label: string; | 98 | label: string; |
| 98 | appearance: ThemeAppearance; | 99 | appearance: ThemeAppearance; |
| 99 | colors: ThemeColors; | 100 | colors: ThemeColors; |
| 100 | variants?: ThemeVariants; | 101 | variants?: ThemeVariants; |
| 102 | /** Groups related imported variants into one library card. */ | ||
| 103 | collection?: ThemeCollection; | ||
| 101 | /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */ | 104 | /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */ |
| 102 | sidebarArtwork?: boolean; | 105 | sidebarArtwork?: boolean; |
| 103 | /** True when the palette was generated by the guided editor from its | 106 | /** True when the palette was generated by the guided editor from its |
| ... | @@ -111,6 +114,7 @@ export type ThemeFile = Readonly<{ | ... | @@ -111,6 +114,7 @@ export type ThemeFile = Readonly<{ |
| 111 | appearance: ThemeAppearance; | 114 | appearance: ThemeAppearance; |
| 112 | colors: ThemeColorOverrides; | 115 | colors: ThemeColorOverrides; |
| 113 | variants?: ThemeVariantOverrides; | 116 | variants?: ThemeVariantOverrides; |
| 117 | collection?: ThemeCollection; | ||
| 114 | managed?: boolean; | 118 | managed?: boolean; |
| 115 | }>; | 119 | }>; |
| 116 | 120 | ||
| ... | @@ -179,6 +183,15 @@ function isThemeLabel(value: unknown): value is string { | ... | @@ -179,6 +183,15 @@ function isThemeLabel(value: unknown): value is string { |
| 179 | return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; | 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 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { | 195 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { |
| 183 | if (!isRecord(value)) return null; | 196 | if (!isRecord(value)) return null; |
| 184 | 197 | ||
| ... | @@ -224,6 +237,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { | ... | @@ -224,6 +237,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { |
| 224 | if (!colors) return null; | 237 | if (!colors) return null; |
| 225 | const variants = parseStoredThemeVariants(value.variants, value.appearance); | 238 | const variants = parseStoredThemeVariants(value.variants, value.appearance); |
| 226 | if (value.variants !== undefined && variants === null) return null; | 239 | if (value.variants !== undefined && variants === null) return null; |
| 240 | const collection = parseThemeCollection(value.collection); | ||
| 227 | 241 | ||
| 228 | return { | 242 | return { |
| 229 | id: value.id, | 243 | id: value.id, |
| ... | @@ -231,10 +245,22 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { | ... | @@ -231,10 +245,22 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { |
| 231 | appearance: value.appearance, | 245 | appearance: value.appearance, |
| 232 | colors, | 246 | colors, |
| 233 | ...(variants ? { variants } : {}), | 247 | ...(variants ? { variants } : {}), |
| 248 | ...(collection ? { collection } : {}), | ||
| 234 | ...(value.managed === true ? { managed: true } : {}), | 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 | function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { | 264 | function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| 239 | if (typeof window === "undefined") { | 265 | if (typeof window === "undefined") { |
| 240 | return { status: "ready", storedThemes: [], themes: [] }; | 266 | return { status: "ready", storedThemes: [], themes: [] }; |
| ... | @@ -256,15 +282,7 @@ function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { | ... | @@ -256,15 +282,7 @@ function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| 256 | } | 282 | } |
| 257 | if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" }; | 283 | if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" }; |
| 258 | 284 | ||
| 259 | const themes: ThemeDefinition[] = []; | 285 | return { status: "ready", storedThemes: parsed, themes: parseStoredThemes(parsed) }; |
| 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 }; | ||
| 268 | } | 286 | } |
| 269 | 287 | ||
| 270 | function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { | 288 | function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| ... | @@ -288,6 +306,14 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { | ... | @@ -288,6 +306,14 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { |
| 288 | return snapshot.status === "ready" ? snapshot.themes : []; | 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 | export function subscribeToCustomThemes(listener: () => void): () => void { | 317 | export function subscribeToCustomThemes(listener: () => void): () => void { |
| 292 | customThemeListeners.add(listener); | 318 | customThemeListeners.add(listener); |
| 293 | if (typeof window === "undefined") { | 319 | if (typeof window === "undefined") { |
| ... | @@ -1598,8 +1624,9 @@ function saveCustomThemes( | ... | @@ -1598,8 +1624,9 @@ function saveCustomThemes( |
| 1598 | notifyCustomThemeListeners(); | 1624 | notifyCustomThemeListeners(); |
| 1599 | } | 1625 | } |
| 1600 | 1626 | ||
| 1601 | function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> { | 1627 | function requireWritableCustomThemeLibrary( |
| 1602 | const snapshot = getCustomThemeLibrarySnapshot(); | 1628 | snapshot: CustomThemeLibrarySnapshot, |
| 1629 | ): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> { | ||
| 1603 | if (snapshot.status === "unavailable") { | 1630 | if (snapshot.status === "unavailable") { |
| 1604 | throw new ThemeLibraryStorageError({ | 1631 | throw new ThemeLibraryStorageError({ |
| 1605 | storageKey: CUSTOM_THEMES_STORAGE_KEY, | 1632 | storageKey: CUSTOM_THEMES_STORAGE_KEY, |
| ... | @@ -1611,10 +1638,29 @@ function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { | ... | @@ -1611,10 +1638,29 @@ function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { |
| 1611 | return snapshot; | 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 | function storedThemeHasId(storedTheme: unknown, themeId: string): boolean { | 1652 | function storedThemeHasId(storedTheme: unknown, themeId: string): boolean { |
| 1615 | return isRecord(storedTheme) && storedTheme.id === themeId; | 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 | export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition { | 1664 | export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1619 | if (RESERVED_THEME_IDS.has(theme.id)) { | 1665 | if (RESERVED_THEME_IDS.has(theme.id)) { |
| 1620 | throw new Error(`The theme id "${theme.id}" is reserved.`); | 1666 | throw new Error(`The theme id "${theme.id}" is reserved.`); |
| ... | @@ -1662,12 +1708,80 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { | ... | @@ -1662,12 +1708,80 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1662 | return canonicalTheme; | 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 | export function removeCustomTheme(themeId: string): void { | 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 | const library = getWritableCustomThemeLibrary(); | 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 | if (nextThemes.length === library.themes.length) return; | 1777 | if (nextThemes.length === library.themes.length) return; |
| 1669 | saveCustomThemes( | 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 | nextThemes, | 1785 | nextThemes, |
| 1672 | ); | 1786 | ); |
| 1673 | } | 1787 | } |
| ... | @@ -1720,6 +1834,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition { | ... | @@ -1720,6 +1834,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition { |
| 1720 | } | 1834 | } |
| 1721 | 1835 | ||
| 1722 | const overrides = parseThemeColorOverrides(rawColors); | 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 | const fallback = getDefaultThemeColors(appearance); | 1842 | const fallback = getDefaultThemeColors(appearance); |
| 1725 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; | 1843 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; |
| ... | @@ -1746,6 +1864,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { | ... | @@ -1746,6 +1864,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { |
| 1746 | appearance, | 1864 | appearance, |
| 1747 | colors: { ...fallback, ...overrides }, | 1865 | colors: { ...fallback, ...overrides }, |
| 1748 | ...(Object.keys(variants).length > 0 ? { variants } : {}), | 1866 | ...(Object.keys(variants).length > 0 ? { variants } : {}), |
| 1867 | ...(collection ? { collection } : {}), | ||
| 1749 | ...(value.managed === true ? { managed: true } : {}), | 1868 | ...(value.managed === true ? { managed: true } : {}), |
| 1750 | }; | 1869 | }; |
| 1751 | } | 1870 | } |
| ... | @@ -1759,6 +1878,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { | ... | @@ -1759,6 +1878,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { |
| 1759 | appearance: canonicalTheme.appearance, | 1878 | appearance: canonicalTheme.appearance, |
| 1760 | colors: canonicalTheme.colors, | 1879 | colors: canonicalTheme.colors, |
| 1761 | ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}), | 1880 | ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}), |
| 1881 | ...(canonicalTheme.collection ? { collection: canonicalTheme.collection } : {}), | ||
| 1762 | ...(canonicalTheme.managed ? { managed: true } : {}), | 1882 | ...(canonicalTheme.managed ? { managed: true } : {}), |
| 1763 | }; | 1883 | }; |
| 1764 | return `${JSON.stringify(file, null, 2)}\n`; | 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,6 +337,9 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { |
| 337 | */ | 337 | */ |
| 338 | export function pairVsCodeThemes( | 338 | export function pairVsCodeThemes( |
| 339 | themes: ReadonlyArray<ThemeDefinition>, | 339 | themes: ReadonlyArray<ThemeDefinition>, |
| 340 | options?: { | ||
| 341 | pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string; | ||
| 342 | }, | ||
| 340 | ): ReadonlyArray<ThemeDefinition> { | 343 | ): ReadonlyArray<ThemeDefinition> { |
| 341 | const stripAppearance = (label: string) => | 344 | const stripAppearance = (label: string) => |
| 342 | label | 345 | label |
| ... | @@ -372,6 +375,7 @@ export function pairVsCodeThemes( | ... | @@ -372,6 +375,7 @@ export function pairVsCodeThemes( |
| 372 | order: group.order, | 375 | order: group.order, |
| 373 | theme: parseThemeFile({ | 376 | theme: parseThemeFile({ |
| 374 | version: THEME_FILE_VERSION, | 377 | version: THEME_FILE_VERSION, |
| 378 | ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}), | ||
| 375 | name: key, | 379 | name: key, |
| 376 | appearance: "light", | 380 | appearance: "light", |
| 377 | colors: group.light[0]!.colors, | 381 | colors: group.light[0]!.colors, |
pnpm-lock.yaml+9| ... | @@ -546,6 +546,9 @@ importers: | ... | @@ -546,6 +546,9 @@ importers: |
| 546 | '@lexical/react': | 546 | '@lexical/react': |
| 547 | specifier: ^0.41.0 | 547 | specifier: ^0.41.0 |
| 548 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) | 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 | '@pierre/diffs': | 552 | '@pierre/diffs': |
| 550 | specifier: 'catalog:' | 553 | specifier: 'catalog:' |
| 551 | 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) | 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,6 +582,12 @@ importers: |
| 579 | jose: | 582 | jose: |
| 580 | specifier: 'catalog:' | 583 | specifier: 'catalog:' |
| 581 | version: 6.2.2 | 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 | lexical: | 591 | lexical: |
| 583 | specifier: ^0.41.0 | 592 | specifier: ^0.41.0 |
| 584 | version: 0.41.0 | 593 | version: 0.41.0 |