History

fix(web): preserve community theme provenance

t3-code[bot] committed 3 months ago

Commit dd8a0db1 parent 62d13075 files · +75 −24Browse files

Unified · Split

5 files
  1. apps/web/src/components/settings/ThemeSearchSection.tsx +34 −11
  2. apps/web/src/openVsxThemes.test.ts +6 −0
  3. apps/web/src/openVsxThemes.ts +2 −2
  4. apps/web/src/themePalette.test.ts +16 −4
  5. apps/web/src/themePalette.ts +17 −7
apps/web/src/components/settings/ThemeSearchSection.tsx+34−11
@@ -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";
15import { Button } from "../ui/button";16import { 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 <div188 <div
166 aria-live="polite"189 aria-live="polite"
apps/web/src/openVsxThemes.test.ts+6−0
@@ -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 () => {
apps/web/src/openVsxThemes.ts+2−2
@@ -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.license655 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 }
apps/web/src/themePalette.test.ts+16−4
@@ -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", () => {
apps/web/src/themePalette.ts+17−7
@@ -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
179function 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
178function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {188function 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 };