Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
installCustomTheme,
invalidateCustomThemes,
parseThemeFile,
themeColorToHex,
THEME_FILE_VERSION,
} from "../../themePalette";
import type { OpenVsxThemeExtension } from "../../openVsxThemes";
Expand Down Expand Up @@ -112,3 +113,82 @@ it.each(["Update existing", "Keep both"])(
expect(onOpenChange).not.toHaveBeenCalledWith(false);
},
);

function isolateThemes() {
const previous = localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY);
localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY);
invalidateCustomThemes();
return {
[Symbol.dispose]() {
if (previous === null) localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY);
else localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, previous);
invalidateCustomThemes();
},
};
}

function themeJsonc(name: string, background: string) {
return `{
// VS Code themes use JSONC even with a .json extension.
"name": "${name}",
"colors": {
"editor.background": "${background}",
},
"tokenColors": [],
}`;
}

describe("ThemeImportDialog JSONC imports", () => {
it.each(["paste", "file"])("imports a VS Code JSONC theme from %s", async (source) => {
using _themes = isolateThemes();
await using _screen = await render(
<ThemeImportDialog
open
onOpenChange={() => {}}
onImported={() => true}
onImportedMany={() => {}}
/>,
);
const json = themeJsonc("JSONC Night", "#002b36");
if (source === "paste") {
await page.getByRole("textbox", { name: "Theme JSON" }).fill(json);
} else {
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
await userEvent.upload(input, new File([json], "night.json", { type: "application/json" }));
await expect.element(page.getByRole("textbox", { name: "Theme JSON" })).toHaveValue(json);
}
await page.getByRole("button", { name: "Add theme", exact: true }).click();

expect(getCustomThemes().map((theme) => theme.label)).toEqual(["JSONC Night"]);
expect(themeColorToHex(getCustomThemes()[0]!.colors.canvas)).toBe("#002b36");
});

it("imports each VS Code JSONC theme in a file batch", async () => {
using _themes = isolateThemes();
await using _screen = await render(
<ThemeImportDialog
open
onOpenChange={() => {}}
onImported={() => true}
onImportedMany={() => {}}
/>,
);
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
await userEvent.upload(input, [
new File([themeJsonc("JSONC Night", "#002b36")], "night.json", {
type: "application/json",
}),
new File([themeJsonc("JSONC Day", "#fdf6e3")], "day.json", {
type: "application/json",
}),
]);

await vi.waitFor(() => {
expect(getCustomThemes().map((theme) => theme.label)).toEqual(["JSONC Night", "JSONC Day"]);
});
expect(getCustomThemes().map((theme) => themeColorToHex(theme.colors.canvas))).toEqual([
"#002b36",
"#fdf6e3",
]);
});
});
11 changes: 3 additions & 8 deletions apps/web/src/components/settings/ThemeImportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import {
} from "../../themePalette";
import {
humanizeThemeName,
isVsCodeThemeFile,
pairVsCodeThemes,
parseVsCodeThemeFile,
parseThemeImport,
resolveThemeLabelCollisions,
} from "../../vscodeThemeImport";
import { Alert } from "../ui/alert";
Expand Down Expand Up @@ -256,10 +255,9 @@ function ThemeImportBody({
continue;
}
try {
const value: unknown = JSON.parse(await file.text());
parsed.push({
sourceName: file.name,
theme: isVsCodeThemeFile(value) ? parseVsCodeThemeFile(value) : parseThemeFile(value),
theme: parseThemeImport(await file.text()),
});
} catch (cause) {
failures.push(
Expand Down Expand Up @@ -397,10 +395,7 @@ function ThemeImportBody({
return;
}
try {
const parsed: unknown = JSON.parse(json);
const theme = isVsCodeThemeFile(parsed)
? parseVsCodeThemeFile(parsed)
: parseThemeFile(parsed);
const theme = parseThemeImport(json);
if (getCustomThemes().some((existing) => existing.id === theme.id)) {
setError(null);
setConflicts([theme]);
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/vscodeThemeImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { describe, expect, it } from "vite-plus/test";

import {
getThemeColorsForMode,
parseThemeFile,
themeColorToHex,
THEME_FILE_VERSION,
toCanonicalThemeColor,
} from "./themePalette";
import {
isVsCodeThemeFile,
pairVsCodeThemes,
parseThemeImport,
parseVsCodeThemeFile,
resolveThemeLabelCollisions,
} from "./vscodeThemeImport";
Expand Down Expand Up @@ -63,6 +65,59 @@ const VSCODE_DARK = {
tokenColors: [],
};

describe("theme import text", () => {
it("preserves conversion of ordinary VS Code JSON", () => {
expect(parseThemeImport(JSON.stringify(VSCODE_DARK))).toEqual(
parseVsCodeThemeFile(VSCODE_DARK),
);
});

it.each([
'{ // line comment\n "name": "Test", "colors": { "editor.background": "#002b36" } }',
'{ /* block comment */ "name": "Test", "colors": { "editor.background": "#002b36" } }',
'{ "name": "Test", "colors": { "editor.background": "#002b36", }, "tokenColors": [ { "scope": ["comment",], "settings": {}, }, ], }',
])("imports VS Code JSONC without changing conversion: %s", (source) => {
const theme = parseThemeImport(source);
expect(theme.label).toBe("Test");
expect(theme.appearance).toBe("dark");
expect(asHex(theme.colors.canvas)).toBe("#002b36");
});

it.each([
"",
'{ "name": "Test", "colors": { "editor.background": "#002b36" }',
'{ "name": "Test" "colors": { "editor.background": "#002b36" } }',
'{ "name": "Test", "colors": { "editor.background": "#002b36" } } /* unterminated',
'{ "name": "Test", "colors": { "editor.background": "#002b36" } } extra',
])("rejects malformed input rather than using parser recovery: %s", (source) => {
expect(() => parseThemeImport(source)).toThrow("That theme file is not valid JSON.");
});

it("preserves native T3 theme parsing and strict JSON syntax", () => {
const nativeTheme = {
version: THEME_FILE_VERSION,
name: "Native theme",
appearance: "dark",
colors: { canvas: "#002b36" },
};
const source = JSON.stringify(nativeTheme);
expect(parseThemeImport(source)).toEqual(parseThemeFile(nativeTheme));
expect(() => parseThemeImport(source.replace("{", "{ /* native theme */"))).toThrow();
expect(() => parseThemeImport(source.slice(0, -1) + ",}")).toThrow();
});

it("still validates theme structure after parsing JSONC", () => {
expect(() =>
parseThemeImport('{ /* not a theme */ "colors": { "editor.foreground": "#fff" } }'),
).toThrow('no "editor.background"');
expect(() =>
parseThemeImport(
'{ "version": 1, "name": "Bad native", "appearance": "dark", "colors": { "unknown": "#fff" } }',
),
).toThrow('"unknown" is not a supported theme color role.');
});
});

describe("VS Code theme import", () => {
it("recognises workbench themes and rejects our own files", () => {
expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true);
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/vscodeThemeImport.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parse, type ParseError } from "jsonc-parser";

import {
createVividThemeColors,
getStandardThemeColors,
Expand Down Expand Up @@ -133,6 +135,15 @@ export function isVsCodeThemeFile(value: unknown): boolean {
return hasWorkbenchColors || Array.isArray(value.tokenColors);
}

export function parseThemeImport(source: string): ThemeDefinition {
const errors: ParseError[] = [];
const value: unknown = parse(source, errors, { allowTrailingComma: true });
if (errors.length > 0) throw new Error("That theme file is not valid JSON.");
return isVsCodeThemeFile(value)
? parseVsCodeThemeFile(value)
: parseThemeFile(JSON.parse(source));
}

function resolveAppearance(value: Record<string, unknown>, canvas: VsCodeRgb): ThemeAppearance {
const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
if (type === "light" || type === "hc-light") return "light";
Expand Down
Loading