Unified · Split
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | getCustomThemes, | 10 | getCustomThemes, |
| 11 | installCustomTheme, | 11 | installCustomTheme, |
| 12 | removeCustomTheme, | 12 | removeCustomTheme, |
| 13 | updateCustomTheme, | ||
| 13 | type ThemeDefinition, | 14 | type ThemeDefinition, |
| 14 | } from "../../themePalette"; | 15 | } from "../../themePalette"; |
| 15 | import { Button } from "../ui/button"; | 16 | import { Button } from "../ui/button"; |
| @@ -95,26 +96,40 @@ export function ThemeSearchSection({ · 79 unmodified lines | |||
| 95 | try { | 96 | try { |
| 96 | const themes = await importOpenVsxThemeExtension(extension, controller.signal); | 97 | const themes = await importOpenVsxThemeExtension(extension, controller.signal); |
| 97 | if (controller.signal.aborted) return; | 98 | if (controller.signal.aborted) return; |
| 98 | const existingIds = new Set(getCustomThemes().map((theme) => theme.id)); | 99 | const existingById = new Map(getCustomThemes().map((theme) => [theme.id, theme])); |
| 99 | const missingThemes = themes.filter((theme) => !existingIds.has(theme.id)); | 100 | const installedIds: string[] = []; |
| 100 | if (missingThemes.length === 0) { | 101 | const replaced: ThemeDefinition[] = []; |
| 101 | throw new Error(`${extension.name} is already added.`); | 102 | const imported: ThemeDefinition[] = []; |
| 102 | } | ||
| 103 | |||
| 104 | const installed: ThemeDefinition[] = []; | ||
| 105 | try { | 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 | } catch (cause) { | 115 | } catch (cause) { |
| 108 | for (const theme of installed) { | 116 | for (const themeId of installedIds) { |
| 109 | try { | 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 | } catch { | 126 | } catch { |
| 112 | // Best effort rollback. The original storage failure is clearer. | 127 | // Best effort rollback. The original storage failure is clearer. |
| 113 | } | 128 | } |
| 114 | } | 129 | } |
| 115 | throw cause; | 130 | throw cause; |
| 116 | } | 131 | } |
| 117 | onInstalled(installed); | 132 | onInstalled(imported); |
| 118 | } catch (cause) { | 133 | } catch (cause) { |
| 119 | if (!controller.signal.aborted) { | 134 | if (!controller.signal.aborted) { |
| 120 | setError(cause instanceof Error ? cause.message : "That theme could not be added."); | 135 | setError(cause instanceof Error ? cause.message : "That theme could not be added."); |
| @@ -161,6 +176,14 @@ export function ThemeSearchSection({ · 40 unmodified lines | |||
| 161 | </Button> | 176 | </Button> |
| 162 | </form> | 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 | {error ? ( | 187 | {error ? ( |
| 165 | <div | 188 | <div |
| 166 | aria-live="polite" | 189 | aria-live="polite" |
| @@ -313,6 +313,12 @@ describe("Open VSX themes", () => { · 312 unmodified lines | |||
| 313 | await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow( | 313 | await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow( |
| 314 | "does not match its advertised license", | 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 | it("stops import work when the request is cancelled", async () => { | 324 | it("stops import work when the request is cancelled", async () => { |
| @@ -651,8 +651,8 @@ export async function importOpenVsxThemeExtension( · 650 unmodified lines | |||
| 651 | throw new Error("That extension package does not match the selected Open VSX theme."); | 651 | throw new Error("That extension package does not match the selected Open VSX theme."); |
| 652 | } | 652 | } |
| 653 | if ( | 653 | if ( |
| 654 | typeof packagedManifest.license === "string" && | 654 | typeof packagedManifest.license !== "string" || |
| 655 | packagedManifest.license.trim() !== extension.license | 655 | packagedManifest.license.trim().toLowerCase() !== extension.license.toLowerCase() |
| 656 | ) { | 656 | ) { |
| 657 | throw new Error("That extension package does not match its advertised license."); | 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 | it("serializes a theme back into the importable file shape", () => { | 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 | expect(JSON.parse(serialized)).toMatchObject({ | 209 | expect(JSON.parse(serialized)).toMatchObject({ |
| 200 | version: THEME_FILE_VERSION, | 210 | version: THEME_FILE_VERSION, |
| 201 | id: T3_CHAT_THEME.id, | 211 | id: theme.id, |
| 202 | name: T3_CHAT_THEME.label, | 212 | name: theme.label, |
| 203 | appearance: "light", | 213 | appearance: "dark", |
| 214 | collection: theme.collection, | ||
| 204 | }); | 215 | }); |
| 216 | expect(parseThemeFile(JSON.parse(serialized)).collection).toEqual(theme.collection); | ||
| 205 | }); | 217 | }); |
| 206 | 218 | ||
| 207 | it("keeps sidebar artwork opt-in through theme files", () => { | 219 | it("keeps sidebar artwork opt-in through theme files", () => { |
| @@ -112,6 +112,7 @@ export type ThemeFile = Readonly<{ · 111 unmodified lines | |||
| 112 | appearance: ThemeAppearance; | 112 | appearance: ThemeAppearance; |
| 113 | colors: ThemeColorOverrides; | 113 | colors: ThemeColorOverrides; |
| 114 | variants?: ThemeVariantOverrides; | 114 | variants?: ThemeVariantOverrides; |
| 115 | collection?: ThemeCollection; | ||
| 115 | sidebarArtwork?: boolean; | 116 | sidebarArtwork?: boolean; |
| 116 | managed?: boolean; | 117 | managed?: boolean; |
| 117 | }>; | 118 | }>; |
| @@ -175,6 +176,15 @@ function isThemeLabel(value: unknown): value is string { · 57 unmodified lines | |||
| 175 | return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; | 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 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { | 188 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { |
| 179 | if (!isRecord(value)) return null; | 189 | if (!isRecord(value)) return null; |
| 180 | 190 | ||
| @@ -219,13 +229,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { · 38 unmodified lines | |||
| 219 | if (!colors) return null; | 229 | if (!colors) return null; |
| 220 | const variants = parseStoredThemeVariants(value.variants, value.appearance); | 230 | const variants = parseStoredThemeVariants(value.variants, value.appearance); |
| 221 | if (value.variants !== undefined && variants === null) return null; | 231 | if (value.variants !== undefined && variants === null) return null; |
| 222 | const collection = | 232 | const collection = parseThemeCollection(value.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; | ||
| 229 | 233 | ||
| 230 | return { | 234 | return { |
| 231 | id: value.id, | 235 | id: value.id, |
| @@ -1547,6 +1551,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition { · 1315 unmodified lines | |||
| 1547 | } | 1551 | } |
| 1548 | 1552 | ||
| 1549 | const overrides = parseThemeColorOverrides(rawColors); | 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 | const fallback = getDefaultThemeColors(appearance); | 1559 | const fallback = getDefaultThemeColors(appearance); |
| 1552 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; | 1560 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; |
| @@ -1573,6 +1581,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { · 20 unmodified lines | |||
| 1573 | appearance, | 1581 | appearance, |
| 1574 | colors: { ...fallback, ...overrides }, | 1582 | colors: { ...fallback, ...overrides }, |
| 1575 | ...(Object.keys(variants).length > 0 ? { variants } : {}), | 1583 | ...(Object.keys(variants).length > 0 ? { variants } : {}), |
| 1584 | ...(collection ? { collection } : {}), | ||
| 1576 | ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), | 1585 | ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), |
| 1577 | ...(value.managed === true ? { managed: true } : {}), | 1586 | ...(value.managed === true ? { managed: true } : {}), |
| 1578 | }; | 1587 | }; |
| @@ -1586,6 +1595,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { · 7 unmodified lines | |||
| 1586 | appearance: theme.appearance, | 1595 | appearance: theme.appearance, |
| 1587 | colors: theme.colors, | 1596 | colors: theme.colors, |
| 1588 | ...(theme.variants ? { variants: theme.variants } : {}), | 1597 | ...(theme.variants ? { variants: theme.variants } : {}), |
| 1598 | ...(theme.collection ? { collection: theme.collection } : {}), | ||
| 1589 | ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), | 1599 | ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), |
| 1590 | ...(theme.managed ? { managed: true } : {}), | 1600 | ...(theme.managed ? { managed: true } : {}), |
| 1591 | }; | 1601 | }; |