Unified · Split
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | getCustomThemes, |
| 11 | 11 | installCustomTheme, |
| 12 | 12 | removeCustomTheme, |
| 13 | + updateCustomTheme, | |
| 13 | 14 | type ThemeDefinition, |
| 14 | 15 | } from "../../themePalette"; |
| 15 | 16 | import { Button } from "../ui/button"; |
| @@ -95,26 +96,40 @@ export function ThemeSearchSection({ · 79 unmodified lines | ||
| 95 | 96 | try { |
| 96 | 97 | const themes = await importOpenVsxThemeExtension(extension, controller.signal); |
| 97 | 98 | if (controller.signal.aborted) return; |
| 98 | − const existingIds = new Set(getCustomThemes().map((theme) => theme.id)); | |
| 99 | − const missingThemes = themes.filter((theme) => !existingIds.has(theme.id)); | |
| 100 | − if (missingThemes.length === 0) { | |
| 101 | − throw new Error(`${extension.name} is already added.`); | |
| 102 | − } | |
| 103 | − | |
| 104 | − const installed: ThemeDefinition[] = []; | |
| 99 | + const existingById = new Map(getCustomThemes().map((theme) => [theme.id, theme])); | |
| 100 | + const installedIds: string[] = []; | |
| 101 | + const replaced: ThemeDefinition[] = []; | |
| 102 | + const imported: ThemeDefinition[] = []; | |
| 105 | 103 | try { |
| 106 | − for (const theme of missingThemes) installed.push(installCustomTheme(theme)); | |
| 104 | + for (const theme of themes) { | |
| 105 | + const existing = existingById.get(theme.id); | |
| 106 | + if (existing) { | |
| 107 | + replaced.push(existing); | |
| 108 | + imported.push(updateCustomTheme(theme)); | |
| 109 | + } else { | |
| 110 | + const installed = installCustomTheme(theme); | |
| 111 | + installedIds.push(installed.id); | |
| 112 | + imported.push(installed); | |
| 113 | + } | |
| 114 | + } | |
| 107 | 115 | } catch (cause) { |
| 108 | − for (const theme of installed) { | |
| 116 | + for (const themeId of installedIds) { | |
| 109 | 117 | try { |
| 110 | − removeCustomTheme(theme.id); | |
| 118 | + removeCustomTheme(themeId); | |
| 119 | + } catch { | |
| 120 | + // Best effort rollback. The original storage failure is clearer. | |
| 121 | + } | |
| 122 | + } | |
| 123 | + for (const theme of replaced) { | |
| 124 | + try { | |
| 125 | + updateCustomTheme(theme); | |
| 111 | 126 | } catch { |
| 112 | 127 | // Best effort rollback. The original storage failure is clearer. |
| 113 | 128 | } |
| 114 | 129 | } |
| 115 | 130 | throw cause; |
| 116 | 131 | } |
| 117 | − onInstalled(installed); | |
| 132 | + onInstalled(imported); | |
| 118 | 133 | } catch (cause) { |
| 119 | 134 | if (!controller.signal.aborted) { |
| 120 | 135 | setError(cause instanceof Error ? cause.message : "That theme could not be added."); |
| @@ -161,6 +176,14 @@ export function ThemeSearchSection({ · 40 unmodified lines | ||
| 161 | 176 | </Button> |
| 162 | 177 | </form> |
| 163 | 178 | |
| 179 | + <div className="sr-only" role="status"> | |
| 180 | + {isSearching | |
| 181 | + ? "Finding themes..." | |
| 182 | + : results | |
| 183 | + ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.` | |
| 184 | + : ""} | |
| 185 | + </div> | |
| 186 | + | |
| 164 | 187 | {error ? ( |
| 165 | 188 | <div |
| 166 | 189 | aria-live="polite" |
| @@ -313,6 +313,12 @@ describe("Open VSX themes", () => { · 312 unmodified lines | ||
| 313 | 313 | await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow( |
| 314 | 314 | "does not match its advertised license", |
| 315 | 315 | ); |
| 316 | + | |
| 317 | + Reflect.deleteProperty(packagedManifest, "license"); | |
| 318 | + await rebuildPackage(); | |
| 319 | + await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow( | |
| 320 | + "does not match its advertised license", | |
| 321 | + ); | |
| 316 | 322 | }); |
| 317 | 323 | |
| 318 | 324 | it("stops import work when the request is cancelled", async () => { |
| @@ -651,8 +651,8 @@ export async function importOpenVsxThemeExtension( · 650 unmodified lines | ||
| 651 | 651 | throw new Error("That extension package does not match the selected Open VSX theme."); |
| 652 | 652 | } |
| 653 | 653 | if ( |
| 654 | − typeof packagedManifest.license === "string" && | |
| 655 | − packagedManifest.license.trim() !== extension.license | |
| 654 | + typeof packagedManifest.license !== "string" || | |
| 655 | + packagedManifest.license.trim().toLowerCase() !== extension.license.toLowerCase() | |
| 656 | 656 | ) { |
| 657 | 657 | throw new Error("That extension package does not match its advertised license."); |
| 658 | 658 | } |
| @@ -195,13 +195,25 @@ describe("theme files", () => { · 194 unmodified lines | ||
| 195 | 195 | }); |
| 196 | 196 | |
| 197 | 197 | it("serializes a theme back into the importable file shape", () => { |
| 198 | − const serialized = serializeThemeFile(T3_CHAT_THEME); | |
| 198 | + const theme = { | |
| 199 | + ...parseThemeFile({ | |
| 200 | + version: THEME_FILE_VERSION, | |
| 201 | + id: "community-demo", | |
| 202 | + name: "Community Demo", | |
| 203 | + appearance: "dark", | |
| 204 | + colors: { canvas: "#111111" }, | |
| 205 | + }), | |
| 206 | + collection: { id: "open-vsx:demo.theme", label: "Demo Theme" }, | |
| 207 | + }; | |
| 208 | + const serialized = serializeThemeFile(theme); | |
| 199 | 209 | expect(JSON.parse(serialized)).toMatchObject({ |
| 200 | 210 | version: THEME_FILE_VERSION, |
| 201 | − id: T3_CHAT_THEME.id, | |
| 202 | − name: T3_CHAT_THEME.label, | |
| 203 | − appearance: "light", | |
| 211 | + id: theme.id, | |
| 212 | + name: theme.label, | |
| 213 | + appearance: "dark", | |
| 214 | + collection: theme.collection, | |
| 204 | 215 | }); |
| 216 | + expect(parseThemeFile(JSON.parse(serialized)).collection).toEqual(theme.collection); | |
| 205 | 217 | }); |
| 206 | 218 | |
| 207 | 219 | it("keeps sidebar artwork opt-in through theme files", () => { |
| @@ -112,6 +112,7 @@ export type ThemeFile = Readonly<{ · 111 unmodified lines | ||
| 112 | 112 | appearance: ThemeAppearance; |
| 113 | 113 | colors: ThemeColorOverrides; |
| 114 | 114 | variants?: ThemeVariantOverrides; |
| 115 | + collection?: ThemeCollection; | |
| 115 | 116 | sidebarArtwork?: boolean; |
| 116 | 117 | managed?: boolean; |
| 117 | 118 | }>; |
| @@ -175,6 +176,15 @@ function isThemeLabel(value: unknown): value is string { · 57 unmodified lines | ||
| 175 | 176 | return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; |
| 176 | 177 | } |
| 177 | 178 | |
| 179 | +function parseThemeCollection(value: unknown): ThemeCollection | undefined { | |
| 180 | + return isRecord(value) && | |
| 181 | + typeof value.id === "string" && | |
| 182 | + /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.id) && | |
| 183 | + isThemeLabel(value.label) | |
| 184 | + ? { id: value.id, label: value.label.trim() } | |
| 185 | + : undefined; | |
| 186 | +} | |
| 187 | + | |
| 178 | 188 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { |
| 179 | 189 | if (!isRecord(value)) return null; |
| 180 | 190 | |
| @@ -219,13 +229,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { · 38 unmodified lines | ||
| 219 | 229 | if (!colors) return null; |
| 220 | 230 | const variants = parseStoredThemeVariants(value.variants, value.appearance); |
| 221 | 231 | if (value.variants !== undefined && variants === null) return null; |
| 222 | − const collection = | |
| 223 | − isRecord(value.collection) && | |
| 224 | − typeof value.collection.id === "string" && | |
| 225 | − /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.collection.id) && | |
| 226 | − isThemeLabel(value.collection.label) | |
| 227 | − ? { id: value.collection.id, label: value.collection.label.trim() } | |
| 228 | − : undefined; | |
| 232 | + const collection = parseThemeCollection(value.collection); | |
| 229 | 233 | |
| 230 | 234 | return { |
| 231 | 235 | id: value.id, |
| @@ -1547,6 +1551,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition { · 1315 unmodified lines | ||
| 1547 | 1551 | } |
| 1548 | 1552 | |
| 1549 | 1553 | const overrides = parseThemeColorOverrides(rawColors); |
| 1554 | + const collection = parseThemeCollection(value.collection); | |
| 1555 | + if (value.collection !== undefined && !collection) { | |
| 1556 | + throw new Error("Theme collections need a valid id and label."); | |
| 1557 | + } | |
| 1550 | 1558 | |
| 1551 | 1559 | const fallback = getDefaultThemeColors(appearance); |
| 1552 | 1560 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; |
| @@ -1573,6 +1581,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { · 20 unmodified lines | ||
| 1573 | 1581 | appearance, |
| 1574 | 1582 | colors: { ...fallback, ...overrides }, |
| 1575 | 1583 | ...(Object.keys(variants).length > 0 ? { variants } : {}), |
| 1584 | + ...(collection ? { collection } : {}), | |
| 1576 | 1585 | ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), |
| 1577 | 1586 | ...(value.managed === true ? { managed: true } : {}), |
| 1578 | 1587 | }; |
| @@ -1586,6 +1595,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { · 7 unmodified lines | ||
| 1586 | 1595 | appearance: theme.appearance, |
| 1587 | 1596 | colors: theme.colors, |
| 1588 | 1597 | ...(theme.variants ? { variants: theme.variants } : {}), |
| 1598 | + ...(theme.collection ? { collection: theme.collection } : {}), | |
| 1589 | 1599 | ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), |
| 1590 | 1600 | ...(theme.managed ? { managed: true } : {}), |
| 1591 | 1601 | }; |