History

fix(web): address open vsx review findings

t3-code[bot] committed 3 months ago

Commit 4e5bafc1 parent 64490d43 files · +43 −24Browse files

Unified · Split

3 files
  1. apps/web/src/components/settings/ThemeSearchDialog.tsx +4 −12
  2. apps/web/src/openVsxThemes.test.ts +19 −2
  3. apps/web/src/openVsxThemes.ts +20 −10
apps/web/src/components/settings/ThemeSearchDialog.tsx+4−12
@@ -1,4 +1,4 @@
1import { CheckCircle2Icon, PlusIcon, SearchIcon, ShieldCheckIcon } from "lucide-react";1import { CheckCircle2Icon, PaletteIcon, PlusIcon, SearchIcon, ShieldCheckIcon } from "lucide-react";
2import type { FormEvent } from "react";2import type { FormEvent } from "react";
3import { useCallback, useEffect, useRef, useState } from "react";3import { useCallback, useEffect, useRef, useState } from "react";
4import {4import {
@@ -223,17 +223,9 @@ export function ThemeSearchDialog({ · 218 unmodified lines
223 key={extension.id}223 key={extension.id}
224 >224 >
225 <div className="flex min-w-0 gap-3">225 <div className="flex min-w-0 gap-3">
226 {extension.iconUrl ? (226 <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
227 <img227 <PaletteIcon className="size-4" />
228 alt=""228 </div>
229 className="size-10 shrink-0 rounded-lg bg-muted object-cover"
230 src={extension.iconUrl}
231 />
232 ) : (
233 <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
234 <SearchIcon className="size-4" />
235 </div>
236 )}
237 <div className="min-w-0 flex-1">229 <div className="min-w-0 flex-1">
238 <h4 className="truncate text-sm font-medium">{extension.name}</h4>230 <h4 className="truncate text-sm font-medium">{extension.name}</h4>
239 <p className="truncate text-muted-foreground text-xs">231 <p className="truncate text-muted-foreground text-xs">
apps/web/src/openVsxThemes.test.ts+19−2
@@ -80,6 +80,25 @@ describe("Open VSX themes", () => { · 79 unmodified lines
80 expect(searchUrl.searchParams.get("size")).toBe("16");80 expect(searchUrl.searchParams.get("size")).toBe("16");
81 });81 });
82 82
83 it("reports an unavailable search when every detail request fails", async () => {
84 vi.stubGlobal(
85 "fetch",
86 vi.fn(async (input: RequestInfo | URL) => {
87 if (String(input).includes("/-/search?")) {
88 return new Response(
89 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
90 { status: 200 },
91 );
92 }
93 return new Response(null, { status: 429 });
94 }),
95 );
96
97 await expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
98 "Open VSX theme details are unavailable",
99 );
100 });
101
83 it("downloads a verified VSIX, reads JSONC includes, and pairs contributed variants", async () => {102 it("downloads a verified VSIX, reads JSONC includes, and pairs contributed variants", async () => {
84 const zip = new JSZip();103 const zip = new JSZip();
85 zip.file(104 zip.file(
@@ -140,7 +159,6 @@ describe("Open VSX themes", () => { · 54 unmodified lines
140 publisher: "demo",159 publisher: "demo",
141 description: "",160 description: "",
142 downloadCount: 1,161 downloadCount: 1,
143 iconUrl: null,
144 license: "MIT",162 license: "MIT",
145 manifestUrl: `${ASSET_ROOT}/package.json`,163 manifestUrl: `${ASSET_ROOT}/package.json`,
146 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,164 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
@@ -184,7 +202,6 @@ describe("Open VSX themes", () => { · 37 unmodified lines
184 publisher: "demo",202 publisher: "demo",
185 description: "",203 description: "",
186 downloadCount: 1,204 downloadCount: 1,
187 iconUrl: null,
188 license: "MIT",205 license: "MIT",
189 manifestUrl: `${ASSET_ROOT}/package.json`,206 manifestUrl: `${ASSET_ROOT}/package.json`,
190 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,207 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
apps/web/src/openVsxThemes.ts+20−10
@@ -39,7 +39,6 @@ export type OpenVsxThemeExtension = { · 38 unmodified lines
39 publisher: string;39 publisher: string;
40 description: string;40 description: string;
41 downloadCount: number;41 downloadCount: number;
42 iconUrl: string | null;
43 manifestUrl: string;42 manifestUrl: string;
44 sha256Url: string;43 sha256Url: string;
45 vsixUrl: string;44 vsixUrl: string;
@@ -96,7 +95,6 @@ function extensionFromDetail(value: unknown): OpenVsxThemeExtension | null { · 50 unmodified lines
96 typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount)95 typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount)
97 ? value.downloadCount96 ? value.downloadCount
98 : 0,97 : 0,
99 iconUrl: trustedOpenVsxUrl(value.files.icon),
100 manifestUrl,98 manifestUrl,
101 sha256Url,99 sha256Url,
102 vsixUrl,100 vsixUrl,
@@ -141,7 +139,7 @@ export async function searchOpenVsxThemes( · 38 unmodified lines
141 identities.slice(0, 16).map(async ([namespace, name]) => {139 identities.slice(0, 16).map(async ([namespace, name]) => {
142 const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`;140 const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`;
143 const detailResponse = await fetch(detailUrl, signal ? { signal } : {});141 const detailResponse = await fetch(detailUrl, signal ? { signal } : {});
144 if (!detailResponse.ok) return null;142 if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable.");
145 const detailBytes = await readCappedResponse(143 const detailBytes = await readCappedResponse(
146 detailResponse,144 detailResponse,
147 MAX_DETAIL_BYTES,145 MAX_DETAIL_BYTES,
@@ -155,9 +153,11 @@ export async function searchOpenVsxThemes( · 7 unmodified lines
155 }),153 }),
156 );154 );
157 if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");155 if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
158 return details156 const completedDetails = details.filter((result) => result.status === "fulfilled");
159 .flatMap((result) => (result.status === "fulfilled" && result.value ? [result.value] : []))157 if (identities.length > 0 && completedDetails.length === 0) {
160 .slice(0, 8);158 throw new Error("Open VSX theme details are unavailable right now.");
159 }
160 return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8);
161}161}
162 162
163function parseJsoncObject(source: string, description: string): Record<string, unknown> {163function parseJsoncObject(source: string, description: string): Record<string, unknown> {
@@ -331,18 +331,25 @@ async function readZipText(zip: JSZip, path: string, description: string): Promi · 167 unmodified lines
331async function loadThemeObject(331async function loadThemeObject(
332 zip: JSZip,332 zip: JSZip,
333 path: string,333 path: string,
334 cache: Map<string, Record<string, unknown>>,
334 ancestors: ReadonlySet<string> = new Set(),335 ancestors: ReadonlySet<string> = new Set(),
335): Promise<Record<string, unknown>> {336): Promise<Record<string, unknown>> {
336 if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply.");337 if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply.");
337 if (ancestors.has(path)) throw new Error("Theme includes contain a cycle.");338 if (ancestors.has(path)) throw new Error("Theme includes contain a cycle.");
339 const cached = cache.get(path);
340 if (cached) return cached;
341
338 const value = parseJsoncObject(await readZipText(zip, path, path), path);342 const value = parseJsoncObject(await readZipText(zip, path, path), path);
339 if (typeof value.include !== "string") return value;343 if (typeof value.include !== "string") {
344 cache.set(path, value);
345 return value;
346 }
340 347
341 const includePath = normalizePackagePath(value.include, path);348 const includePath = normalizePackagePath(value.include, path);
342 const nextAncestors = new Set(ancestors);349 const nextAncestors = new Set(ancestors);
343 nextAncestors.add(path);350 nextAncestors.add(path);
344 const base = await loadThemeObject(zip, includePath, nextAncestors);351 const base = await loadThemeObject(zip, includePath, cache, nextAncestors);
345 return {352 const resolved = {
346 ...base,353 ...base,
347 ...value,354 ...value,
348 colors: {355 colors: {
@@ -350,6 +357,8 @@ async function loadThemeObject( · 1 unmodified line
350 ...(isRecord(value.colors) ? value.colors : {}),357 ...(isRecord(value.colors) ? value.colors : {}),
351 },358 },
352 };359 };
360 cache.set(path, resolved);
361 return resolved;
353}362}
354 363
355async function readCappedResponse(364async function readCappedResponse(
@@ -457,11 +466,12 @@ export async function importOpenVsxThemeExtension( · 101 unmodified lines
457 466
458 const parsed: Array<{ theme: ThemeDefinition; sourceName: string }> = [];467 const parsed: Array<{ theme: ThemeDefinition; sourceName: string }> = [];
459 const failures: string[] = [];468 const failures: string[] = [];
469 const themeCache = new Map<string, Record<string, unknown>>();
460 for (const contribution of contributions as ThemeContribution[]) {470 for (const contribution of contributions as ThemeContribution[]) {
461 if (typeof contribution.path !== "string") continue;471 if (typeof contribution.path !== "string") continue;
462 try {472 try {
463 const path = normalizePackagePath(contribution.path);473 const path = normalizePackagePath(contribution.path);
464 const themeValue = await loadThemeObject(zip, path);474 const themeValue = await loadThemeObject(zip, path, themeCache);
465 const type = contributionType(contribution.uiTheme);475 const type = contributionType(contribution.uiTheme);
466 const label =476 const label =
467 typeof contribution.label === "string" && contribution.label.trim()477 typeof contribution.label === "string" && contribution.label.trim()