mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(settings): import and export editor color schemes (#518)
The Color Scheme page only offered built-in schemes, so a scheme from another IDE could not be used. Import reads .icls files into named custom schemes in user settings, reports applied and skipped values, and rejects malformed files. Export writes the current scheme, replacing an existing file, and custom schemes can be deleted. Colours are validated before reaching a stylesheet, and project settings cannot define schemes. The Appearance selector and Quick Switch list custom schemes too. Fixes #513
This commit is contained in:
@@ -342,6 +342,15 @@ impl Default for AppearanceSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Imported editor colour scheme on top of a built-in base.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomColorScheme {
|
||||
pub name: String,
|
||||
pub parent: String,
|
||||
pub attributes: BTreeMap<String, TextAttributesOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EditorSettings {
|
||||
@@ -357,6 +366,8 @@ pub struct EditorSettings {
|
||||
pub format_on_save: bool,
|
||||
/// Editor colour scheme id; `theme-default` follows the IDE theme.
|
||||
pub color_scheme: String,
|
||||
/// Imported editor schemes, stored in user settings.
|
||||
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
|
||||
/// Color Scheme edits: scheme id → attribute id → override.
|
||||
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
@@ -404,6 +415,7 @@ impl Default for EditorSettings {
|
||||
format_on_save: false,
|
||||
color_scheme: "theme-default".into(),
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
|
||||
@@ -27,6 +27,12 @@ pub enum SettingsScope {
|
||||
Project,
|
||||
}
|
||||
|
||||
fn remove_custom_schemes(layer: &mut Json) {
|
||||
if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) {
|
||||
editor.remove("customSchemes");
|
||||
}
|
||||
}
|
||||
|
||||
fn defaults_json() -> Json {
|
||||
serde_json::to_value(Settings::default()).expect("settings serialize")
|
||||
}
|
||||
@@ -178,10 +184,12 @@ impl SettingsStore {
|
||||
|
||||
fn compute(&self) -> Settings {
|
||||
let mut merged = defaults_json();
|
||||
for (_, file) in self.layers() {
|
||||
if let Some(layer) = read_layer(&file)
|
||||
&& let Err(e) = try_merge(&mut merged, layer)
|
||||
{
|
||||
for (scope, file) in self.layers() {
|
||||
let Some(mut layer) = read_layer(&file) else { continue };
|
||||
if scope == SettingsScope::Project {
|
||||
remove_custom_schemes(&mut layer);
|
||||
}
|
||||
if let Err(e) = try_merge(&mut merged, layer) {
|
||||
tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display());
|
||||
}
|
||||
}
|
||||
@@ -205,6 +213,13 @@ impl SettingsStore {
|
||||
/// `null` removes the key from that layer. Unknown keys and values of the
|
||||
/// wrong type are rejected with `InvalidInput`; the file is left untouched.
|
||||
pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult<Settings> {
|
||||
if scope == SettingsScope::Project
|
||||
&& (key == "editor.customSchemes"
|
||||
|| key.starts_with("editor.customSchemes.")
|
||||
|| (key == "editor" && value.get("customSchemes").is_some()))
|
||||
{
|
||||
return Err(IdeError::invalid("custom color schemes belong to user settings"));
|
||||
}
|
||||
let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf();
|
||||
let segments: Vec<&str> = key.split('.').collect();
|
||||
if segments.iter().any(|s| s.is_empty()) {
|
||||
@@ -242,7 +257,10 @@ impl SettingsStore {
|
||||
for (layer_scope, layer_file) in self.layers() {
|
||||
let is_target = layer_scope == scope;
|
||||
let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) };
|
||||
let Some(layer) = layer else { continue };
|
||||
let Some(mut layer) = layer else { continue };
|
||||
if layer_scope == SettingsScope::Project {
|
||||
remove_custom_schemes(&mut layer);
|
||||
}
|
||||
if let Err(e) = try_merge(&mut merged, layer)
|
||||
&& is_target
|
||||
{
|
||||
@@ -316,6 +334,38 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn custom_color_schemes_are_user_settings_and_survive_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = tmp.path().join("cfg");
|
||||
let proj = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(proj.join(".openidea")).unwrap();
|
||||
let store = SettingsStore::load(&cfg, Some(&proj));
|
||||
let schemes = json!({"custom-forest": {"name": "Forest", "parent": "dark", "attributes": {
|
||||
"keyword": {"foreground": "#99dd77", "bold": true}
|
||||
}}});
|
||||
store.set(SettingsScope::Global, "editor.customSchemes", schemes.clone()).unwrap();
|
||||
store.set(SettingsScope::Global, "editor.colorScheme", json!("custom-forest")).unwrap();
|
||||
let loaded = SettingsStore::load(&cfg, Some(&proj)).effective();
|
||||
assert_eq!(loaded.editor.color_scheme, "custom-forest");
|
||||
assert_eq!(serde_json::to_value(&loaded.editor.custom_schemes).unwrap(), schemes);
|
||||
assert!(store.set(SettingsScope::Project, "editor.customSchemes", schemes.clone()).is_err());
|
||||
assert!(
|
||||
store
|
||||
.set(SettingsScope::Project, "editor.customSchemes.custom-other", schemes["custom-forest"].clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.set(SettingsScope::Project, "editor", json!({"customSchemes": schemes})).is_err());
|
||||
std::fs::write(
|
||||
proj.join(".openidea/settings.toml"),
|
||||
"[editor.customSchemes.custom-forest]\nname = \"Project\"\nparent = \"light\"\nattributes = {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.reload().editor.custom_schemes["custom-forest"].name, "Forest");
|
||||
store.set(SettingsScope::Global, "editor.customSchemes", json!({})).unwrap();
|
||||
assert!(SettingsStore::load(&cfg, Some(&proj)).effective().editor.custom_schemes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layering_and_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1992,6 +1992,23 @@ export type CreatePullRequest = {
|
||||
push: boolean,
|
||||
};
|
||||
|
||||
/** Imported editor colour scheme on top of a built-in base. */
|
||||
export type CustomColorScheme = CustomColorScheme_Serialize | CustomColorScheme_Deserialize;
|
||||
|
||||
/** Imported editor colour scheme on top of a built-in base. */
|
||||
export type CustomColorScheme_Deserialize = {
|
||||
name: string,
|
||||
parent: string,
|
||||
attributes: { [key in string]: TextAttributesOverride_Deserialize },
|
||||
};
|
||||
|
||||
/** Imported editor colour scheme on top of a built-in base. */
|
||||
export type CustomColorScheme_Serialize = {
|
||||
name: string,
|
||||
parent: string,
|
||||
attributes: { [key in string]: TextAttributesOverride_Serialize },
|
||||
};
|
||||
|
||||
/** A named profile made with Copy. */
|
||||
export type CustomInspectionProfile = {
|
||||
name?: string,
|
||||
@@ -2738,6 +2755,8 @@ export type EditorSettings_Deserialize = {
|
||||
formatOnSave?: boolean,
|
||||
/** Editor colour scheme id; `theme-default` follows the IDE theme. */
|
||||
colorScheme?: string,
|
||||
/** Imported editor schemes, stored in user settings. */
|
||||
customSchemes?: { [key in string]: CustomColorScheme_Deserialize },
|
||||
/** Color Scheme edits: scheme id → attribute id → override. */
|
||||
colorOverrides?: { [key in string]: { [key in string]: TextAttributesOverride_Deserialize } },
|
||||
/**
|
||||
@@ -2785,6 +2804,8 @@ export type EditorSettings_Serialize = {
|
||||
formatOnSave: boolean,
|
||||
/** Editor colour scheme id; `theme-default` follows the IDE theme. */
|
||||
colorScheme: string,
|
||||
/** Imported editor schemes, stored in user settings. */
|
||||
customSchemes: { [key in string]: CustomColorScheme_Serialize },
|
||||
/** Color Scheme edits: scheme id → attribute id → override. */
|
||||
colorOverrides: { [key in string]: { [key in string]: TextAttributesOverride_Serialize } },
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@ export function installColorScheme(): () => void {
|
||||
const apply = () => {
|
||||
const settings = useProject.getState().settings;
|
||||
const prefs = editorPrefs(settings);
|
||||
const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides);
|
||||
const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides, undefined, prefs.customSchemes);
|
||||
if (el.textContent !== css) el.textContent = css;
|
||||
for (const [name, value] of Object.entries(fontWeightVars(prefs.fontWeight, prefs.boldFontWeight)))
|
||||
if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { DEFAULT_SETTINGS } from "../model/schema";
|
||||
import { openSettings, stage, stagedValue, useSettingsDialog } from "../model/staging";
|
||||
import { PageContext } from "../components/context";
|
||||
import { COLOR_SCHEME_PAGE } from "../pages/editor/colorScheme";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
open: vi.fn(),
|
||||
save: vi.fn(),
|
||||
read: vi.fn(),
|
||||
write: vi.fn(),
|
||||
ask: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open, save: mocks.save }));
|
||||
vi.mock("@/workbench/messages", () => ({ ask: mocks.ask, confirm: mocks.confirm, showError: mocks.error }));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const actual = await original<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, fsReadText: mocks.read, fsCreateFileBytes: mocks.write } };
|
||||
});
|
||||
const value = (key: string) => stagedValue(useSettingsDialog.getState(), "global", `editor.${key}`);
|
||||
const run = (name: string) =>
|
||||
act(async () => {
|
||||
await executeAction(`editor.colorScheme.${name}`);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.open.mockResolvedValue("/schemes/forest.icls");
|
||||
mocks.save.mockResolvedValue("/schemes/export.icls");
|
||||
mocks.read.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: readFileSync("src/platform/__fixtures__/colorSchemes/forest.icls", "utf8"),
|
||||
});
|
||||
mocks.write.mockResolvedValue({ status: "ok", data: null });
|
||||
useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null });
|
||||
openSettings("editor.colorScheme");
|
||||
const Page = COLOR_SCHEME_PAGE.component!;
|
||||
render(
|
||||
<PageContext.Provider value={{ pageId: "editor.colorScheme", scope: "project" }}>
|
||||
<Page />
|
||||
</PageContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
describe("scheme actions", () => {
|
||||
it("imports and selects in global scope, exports and confirms deletion", async () => {
|
||||
await run("import");
|
||||
const id = value("colorScheme") as string;
|
||||
expect(id).toMatch(/^custom-/);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Applied 16 values; skipped 2");
|
||||
expect(Object.values(useSettingsDialog.getState().staged).every((s) => s.scope === "global")).toBe(true);
|
||||
await run("export");
|
||||
expect(mocks.write).toHaveBeenCalledWith("/schemes/export.icls", expect.any(Array));
|
||||
expect(new TextDecoder().decode(new Uint8Array(mocks.write.mock.calls[0]![1]))).toContain(
|
||||
'name="Forest & Moss"',
|
||||
);
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
await run("delete");
|
||||
expect(value("colorScheme")).toBe(id);
|
||||
mocks.confirm.mockResolvedValueOnce(true);
|
||||
await run("delete");
|
||||
expect(value("colorScheme")).toBe("theme-default");
|
||||
expect(value("customSchemes")).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts file paths from action callers and keeps the same import/export flow", async () => {
|
||||
await act(async () => {
|
||||
await executeAction("editor.colorScheme.import", "/chosen/custom.icls");
|
||||
});
|
||||
expect(mocks.open).not.toHaveBeenCalled();
|
||||
expect(mocks.read).toHaveBeenCalledWith("/chosen/custom.icls");
|
||||
expect(value("colorScheme")).toMatch(/^custom-/);
|
||||
await act(async () => {
|
||||
await executeAction("editor.colorScheme.export", "/chosen/custom-export.icls");
|
||||
});
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
expect(mocks.write).toHaveBeenCalledWith("/chosen/custom-export.icls", expect.any(Array));
|
||||
});
|
||||
|
||||
it("supports cancel, keep both and overwrite collisions", async () => {
|
||||
await run("import");
|
||||
const first = value("colorScheme");
|
||||
mocks.ask.mockResolvedValueOnce({ button: "cancel" });
|
||||
await run("import");
|
||||
expect(value("colorScheme")).toBe(first);
|
||||
mocks.ask.mockResolvedValueOnce({ button: "both" });
|
||||
await run("import");
|
||||
expect(Object.values(value("customSchemes") as Record<string, { name: string }>).map((s) => s.name)).toEqual([
|
||||
"Forest & Moss",
|
||||
"Forest & Moss (2)",
|
||||
]);
|
||||
act(() =>
|
||||
stage("editor.colorScheme", "global", "editor.colorOverrides", {
|
||||
[first as string]: { keyword: { foreground: "#ff0000" } },
|
||||
}),
|
||||
);
|
||||
mocks.ask.mockResolvedValueOnce({ button: "overwrite" });
|
||||
await run("import");
|
||||
expect(value("colorScheme")).toBe(first);
|
||||
expect(value("colorOverrides")).toEqual({});
|
||||
expect(Object.keys(value("customSchemes") as object)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a malformed-file error without staging any changes", async () => {
|
||||
mocks.read.mockResolvedValueOnce({ status: "ok", data: '<scheme name="bad">' });
|
||||
await run("import");
|
||||
await waitFor(() => expect(mocks.error).toHaveBeenCalledWith("Color Scheme", "Invalid .icls color scheme"));
|
||||
expect(useSettingsDialog.getState().staged).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { SCHEMES as EDITOR_SCHEMES } from "@/platform/colorScheme";
|
||||
import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme";
|
||||
import { formatTimestamp } from "@/platform/dateFormat";
|
||||
import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
|
||||
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
|
||||
@@ -117,7 +117,8 @@ const SCHEMES: SelectOption[] = [
|
||||
|
||||
function ColorSchemeRow() {
|
||||
const [scheme, setScheme] = useSetting<string>("editor.colorScheme");
|
||||
const options = SCHEMES.some((s) => s.value === scheme) ? SCHEMES : [...SCHEMES, { value: scheme, label: scheme }];
|
||||
const [customs] = useSetting<unknown>("editor.customSchemes", "global");
|
||||
const options = [SCHEMES[0]!, ...listSchemes(customSchemes(customs)).map((s) => ({ value: s.id, label: s.name }))];
|
||||
return (
|
||||
<Indented>
|
||||
<Row label="Editor color scheme">
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
/* Color Scheme page */
|
||||
.schemeRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@@ -4,32 +4,32 @@
|
||||
* italic / effects editor, and a live preview. Edits are staged in
|
||||
* `editor.colorOverrides[scheme][attribute]`; `editor/core` injects them.
|
||||
*/
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { Button, Checkbox, Input, Tree, type SelectOption, T } from "@/ui";
|
||||
import { fontWeightVars, type TextAttributesOverride } from "@/platform/editorSettings";
|
||||
import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } from "@/platform/editorSettings";
|
||||
import {
|
||||
ATTRIBUTES,
|
||||
SCHEMES,
|
||||
boldWeight,
|
||||
overrideDeclarations,
|
||||
effectiveScheme,
|
||||
schemeTokens,
|
||||
resolvedSchemeTokens,
|
||||
listSchemes,
|
||||
customSchemes,
|
||||
type AttributeDef,
|
||||
type SchemeId,
|
||||
} from "@/platform/colorScheme";
|
||||
import { HEX_COLOR } from "../../model/schema";
|
||||
import { Row, Select } from "../../components/Form";
|
||||
import { useSetting } from "../../components/context";
|
||||
import { editorPage } from "./kit";
|
||||
import styles from "./Editor.module.css";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { ask, confirm, showError } from "@/workbench/messages";
|
||||
import { parseIcls, importScheme, deleteScheme, exportIcls } from "@/platform/icls";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
type Overrides = Record<string, Record<string, TextAttributesOverride>>;
|
||||
|
||||
const SCHEME_OPTIONS: SelectOption[] = [
|
||||
{ value: "theme-default", label: "Theme default" },
|
||||
...SCHEMES.map((s) => ({ value: s.id, label: s.name })),
|
||||
];
|
||||
|
||||
interface Node {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -56,9 +56,14 @@ function buildTree(): Node[] {
|
||||
const TREE = buildTree();
|
||||
|
||||
/** CSS custom properties of a scheme plus pending overrides (for the preview). */
|
||||
function previewVars(id: SchemeId, o: Record<string, TextAttributesOverride> | undefined): CSSProperties {
|
||||
function previewVars(
|
||||
id: string,
|
||||
o: Record<string, TextAttributesOverride> | undefined,
|
||||
customs: CustomColorSchemes,
|
||||
): CSSProperties {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [k, v] of schemeTokens(id))
|
||||
const tokens = resolvedSchemeTokens(id, customs);
|
||||
for (const [k, v] of tokens)
|
||||
if (
|
||||
k.startsWith("--syn-") ||
|
||||
k.startsWith("--fg-") ||
|
||||
@@ -67,21 +72,8 @@ function previewVars(id: SchemeId, o: Record<string, TextAttributesOverride> | u
|
||||
k === "--caret"
|
||||
)
|
||||
vars[k] = v;
|
||||
vars["--bg-editor"] = schemeTokens(id).get("--bg-editor") ?? schemeTokens(id).get("--bg-island") ?? "";
|
||||
for (const attr of ATTRIBUTES) {
|
||||
const ov = o?.[attr.id];
|
||||
if (!ov) continue;
|
||||
if (ov.foreground && attr.fg) vars[attr.fg] = ov.foreground;
|
||||
if (ov.background && attr.bg) vars[attr.bg] = ov.background;
|
||||
if (attr.token) {
|
||||
if (ov.bold !== undefined) vars[`--syn-${attr.token}-bold`] = boldWeight(ov.bold);
|
||||
if (ov.italic !== undefined) vars[`--syn-${attr.token}-italic`] = ov.italic ? "italic" : "normal";
|
||||
if (ov.background) vars[`--syn-${attr.token}-bg`] = ov.background;
|
||||
if (ov.effect && ov.effect !== "none")
|
||||
vars[`--syn-${attr.token}-effect`] =
|
||||
`${ov.effect === "strikeout" ? "line-through" : ov.effect === "wave" ? "underline wavy" : "underline"} ${ov.effectColor ?? "currentColor"}`;
|
||||
}
|
||||
}
|
||||
vars["--bg-editor"] = tokens.get("--bg-editor") ?? tokens.get("--bg-island") ?? "";
|
||||
for (const [key, value] of overrideDeclarations(o)) vars[key] = value;
|
||||
return vars as CSSProperties;
|
||||
}
|
||||
|
||||
@@ -270,17 +262,21 @@ function ColorRow({
|
||||
}
|
||||
|
||||
function ColorSchemePage() {
|
||||
const [scheme, setScheme] = useSetting<string>("editor.colorScheme");
|
||||
const [overrides, setOverrides] = useSetting<Overrides>("editor.colorOverrides");
|
||||
const [scheme, setScheme] = useSetting<string>("editor.colorScheme", "global");
|
||||
const [overrides, setOverrides] = useSetting<Overrides>("editor.colorOverrides", "global");
|
||||
const [weight] = useSetting<number>("editor.fontWeight");
|
||||
const [boldFontWeight] = useSetting<number>("editor.boldFontWeight");
|
||||
const [rawCustoms, setCustoms] = useSetting<CustomColorSchemes>("editor.customSchemes", "global");
|
||||
const customs = useMemo(() => customSchemes(rawCustoms), [rawCustoms]);
|
||||
const [report, setReport] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selected, setSelected] = useState<string>("keyword");
|
||||
const id = effectiveScheme(scheme);
|
||||
const id = effectiveScheme(scheme, undefined, customs);
|
||||
const mine = overrides?.[id];
|
||||
const attr = ATTRIBUTES.find((a) => a.id === selected);
|
||||
const vars = useMemo(
|
||||
() => ({ ...previewVars(id, mine), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties,
|
||||
[id, mine, weight, boldFontWeight],
|
||||
() => ({ ...previewVars(id, mine, customs), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties,
|
||||
[id, mine, customs, weight, boldFontWeight],
|
||||
);
|
||||
const update = (attrId: string, v: TextAttributesOverride | undefined) => {
|
||||
const next: Overrides = { ...(overrides ?? {}) };
|
||||
@@ -291,13 +287,128 @@ function ColorSchemePage() {
|
||||
else delete next[id];
|
||||
setOverrides(next);
|
||||
};
|
||||
const schemeName = SCHEMES.find((s) => s.id === id)?.name ?? id;
|
||||
const schemes = listSchemes(customs);
|
||||
const schemeName = schemes.find((s) => s.id === id)?.name ?? id;
|
||||
const options: SelectOption[] = [
|
||||
{ value: "theme-default", label: "Theme default" },
|
||||
...schemes.map((s) => ({ value: s.id, label: s.name })),
|
||||
];
|
||||
const handlers = useRef<Record<string, (path?: string) => Promise<void>>>({});
|
||||
useEffect(() => {
|
||||
handlers.current = {
|
||||
import: async (file) => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const path =
|
||||
file ??
|
||||
(await open({
|
||||
multiple: false,
|
||||
filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }],
|
||||
}));
|
||||
if (!path) return;
|
||||
const parsed = parseIcls(await call(commands.fsReadText(path)));
|
||||
let overwrite = false;
|
||||
if (schemes.some((s) => s.name === parsed.scheme.name)) {
|
||||
const canOverwrite = Object.values(customs).some((s) => s.name === parsed.scheme.name);
|
||||
const { button } = await ask({
|
||||
title: tx("Import Scheme"),
|
||||
body: tx`A scheme named '${parsed.scheme.name}' already exists.`,
|
||||
buttons: [
|
||||
...(canOverwrite ? [{ id: "overwrite", label: tx("Overwrite") }] : []),
|
||||
{ id: "both", label: tx("Keep Both"), primary: true },
|
||||
],
|
||||
});
|
||||
if (button !== "overwrite" && button !== "both") return;
|
||||
overwrite = button === "overwrite";
|
||||
}
|
||||
const next = importScheme(customs, parsed.scheme, overwrite);
|
||||
setCustoms(next.customs);
|
||||
if (overwrite)
|
||||
setOverrides(Object.fromEntries(Object.entries(overrides ?? {}).filter(([key]) => key !== next.id)));
|
||||
setScheme(next.id);
|
||||
setReport(tx`Applied ${parsed.applied} values; skipped ${parsed.skipped} unsupported or invalid values.`);
|
||||
},
|
||||
export: async (file) => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path =
|
||||
file ??
|
||||
(await save({
|
||||
defaultPath: `${schemeName.replace(/[/\\:*?"<>|]/g, "_")}.icls`,
|
||||
filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }],
|
||||
}));
|
||||
if (!path) return;
|
||||
const bytes = Array.from(new TextEncoder().encode(exportIcls(id, customs, mine)));
|
||||
// The save dialog already confirmed replacing an existing file.
|
||||
await call(commands.fsDelete(path)).catch(() => {});
|
||||
await call(commands.fsCreateFileBytes(path, bytes));
|
||||
setReport(tx("Scheme exported."));
|
||||
},
|
||||
delete: async () => {
|
||||
if (
|
||||
!customs[id] ||
|
||||
!(await confirm({
|
||||
title: tx("Delete Scheme"),
|
||||
message: tx`Delete color scheme '${schemeName}'?`,
|
||||
ok: tx("Delete"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
const next = deleteScheme(customs, overrides ?? {}, scheme, id);
|
||||
setCustoms(next.customs);
|
||||
setOverrides(next.overrides);
|
||||
setScheme(next.selected);
|
||||
setReport("");
|
||||
},
|
||||
};
|
||||
});
|
||||
const pending = useRef(false);
|
||||
useEffect(
|
||||
() =>
|
||||
registerActions(
|
||||
(["import", "export", "delete"] as const).map((name) => ({
|
||||
id: `editor.colorScheme.${name}`,
|
||||
title: name === "import" ? "Import Scheme" : name === "export" ? "Export Scheme" : "Delete Scheme",
|
||||
category: "Editor",
|
||||
run: async ({ arg }) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
await handlers.current[name]!(typeof arg === "string" ? arg : undefined);
|
||||
} catch (error) {
|
||||
await showError(
|
||||
tx("Color Scheme"),
|
||||
error instanceof Error && error.message === "Invalid .icls color scheme"
|
||||
? tx("Invalid .icls color scheme")
|
||||
: String(error),
|
||||
);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
})),
|
||||
),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.schemeRow}>
|
||||
<Row label="Scheme">
|
||||
<Select value={scheme} options={SCHEME_OPTIONS} onChange={setScheme} aria-label="Scheme" />
|
||||
<Select value={scheme} options={options} onChange={setScheme} aria-label="Scheme" />
|
||||
</Row>
|
||||
<Button size="small" disabled={busy} onClick={() => void executeAction("editor.colorScheme.import")}>
|
||||
<T>Import Scheme</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={busy} onClick={() => void executeAction("editor.colorScheme.export")}>
|
||||
<T>Export Scheme</T>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={busy || !customs[id]}
|
||||
onClick={() => void executeAction("editor.colorScheme.delete")}
|
||||
>
|
||||
<T>Delete Scheme</T>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!mine}
|
||||
@@ -306,6 +417,11 @@ function ColorSchemePage() {
|
||||
<T>Restore Defaults</T>
|
||||
</Button>
|
||||
</div>
|
||||
{report && (
|
||||
<div role="status" className={styles.note}>
|
||||
{report}
|
||||
</div>
|
||||
)}
|
||||
{scheme === "theme-default" && (
|
||||
<div className={styles.note}>{tx`Follows the IDE theme: currently ${schemeName}.`}</div>
|
||||
)}
|
||||
@@ -364,6 +480,7 @@ export const COLOR_SCHEME_PAGE = editorPage({
|
||||
id: "editor.colorScheme",
|
||||
title: "Color Scheme",
|
||||
order: 23,
|
||||
projectScope: false,
|
||||
keywords: [
|
||||
"colors",
|
||||
"syntax highlighting",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<scheme name="Daylight" version="142" parent_scheme="Default">
|
||||
<colors><option name="SELECTION_BACKGROUND" value="ccddff"/></colors>
|
||||
<attributes>
|
||||
<option name="TEXT"><value><option name="FOREGROUND" value="202030"/><option name="BACKGROUND" value="f8f8f0"/></value></option>
|
||||
<option name="KOTLIN_KEYWORD"><value><option name="FOREGROUND" value="663399"/></value></option>
|
||||
<option name="JAVA_STRING"><value><option name="FOREGROUND" value="006633"/></value></option>
|
||||
</attributes>
|
||||
</scheme>
|
||||
@@ -0,0 +1,21 @@
|
||||
<scheme name="Forest & Moss" version="142" parent_scheme="Darcula">
|
||||
<metaInfo><property name="ide">idea</property></metaInfo>
|
||||
<colors>
|
||||
<option name="CARET_COLOR" value="f0e080" />
|
||||
<option name="CARET_ROW_COLOR" value="243829" />
|
||||
<option name="SELECTION_BACKGROUND" value="406040" />
|
||||
<option name="GUTTER_BACKGROUND" value="142018" />
|
||||
<option name="LINE_NUMBERS_COLOR" value="708070" />
|
||||
<option name="UNKNOWN_COLOR" value="123456" />
|
||||
</colors>
|
||||
<attributes>
|
||||
<option name="TEXT"><value><option name="FOREGROUND" value="d8ebd8"/><option name="BACKGROUND" value="18281e"/></value></option>
|
||||
<option name="DEFAULT_KEYWORD"><value><option name="FOREGROUND" value="aadd88"/></value></option>
|
||||
<option name="JAVA_KEYWORD"><value><option name="FOREGROUND" value="99dd77"/><option name="FONT_TYPE" value="1"/></value></option>
|
||||
<option name="KOTLIN_FUNCTION_DECLARATION"><value><option name="FOREGROUND" value="e0c080"/><option name="FONT_TYPE" value="2"/></value></option>
|
||||
<option name="DEFAULT_STRING"><value><option name="FOREGROUND" value="88ddbb"/></value></option>
|
||||
<option name="SEARCH_RESULT_ATTRIBUTES"><value><option name="BACKGROUND" value="6a5530"/></value></option>
|
||||
<option name="ERRORS_ATTRIBUTES"><value><option name="EFFECT_COLOR" value="ff6655"/><option name="EFFECT_TYPE" value="1"/></value></option>
|
||||
<option name="UNKNOWN_LANGUAGE_TOKEN"><value><option name="FOREGROUND" value="123456"/></value></option>
|
||||
</attributes>
|
||||
</scheme>
|
||||
@@ -21,6 +21,7 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"editor.boldFontWeight",
|
||||
"editor.colorScheme",
|
||||
"editor.colorOverrides",
|
||||
"editor.customSchemes",
|
||||
"editor.semanticHighlighting",
|
||||
"editor.rainbowBrackets",
|
||||
"editor.softWrap",
|
||||
|
||||
+59
-11
@@ -8,7 +8,7 @@
|
||||
* Attribute ids are stable (they key `editor.colorOverrides[scheme][id]`).
|
||||
*/
|
||||
import tokensCss from "@/styles/tokens.css?raw";
|
||||
import type { TextAttributesOverride } from "./editorSettings";
|
||||
import type { CustomColorSchemes, TextAttributesOverride } from "./editorSettings";
|
||||
import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "./themes";
|
||||
|
||||
export type SchemeId = BuiltinThemeId;
|
||||
@@ -253,8 +253,9 @@ export function schemeTokens(id: SchemeId, css: string = source): Vars {
|
||||
export function effectiveScheme(
|
||||
setting: string | undefined,
|
||||
root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null,
|
||||
): SchemeId {
|
||||
if (isBuiltinTheme(setting)) return setting;
|
||||
customs: CustomColorSchemes = {},
|
||||
): string {
|
||||
if (isBuiltinTheme(setting) || (setting && Object.hasOwn(customs, setting))) return setting;
|
||||
return themeScheme(root);
|
||||
}
|
||||
|
||||
@@ -289,10 +290,10 @@ export function overrideDeclarations(
|
||||
for (const attr of ATTRIBUTES) {
|
||||
const o = overrides[attr.id];
|
||||
if (!o) continue;
|
||||
if (o.foreground && attr.fg) out.push([attr.fg, o.foreground]);
|
||||
if (o.background && attr.bg) out.push([attr.bg, o.background]);
|
||||
if (o.effectColor && attr.effect) out.push([attr.effect, o.effectColor]);
|
||||
if (attr.effect && o.effectColor) {
|
||||
if (isHexColor(o.foreground) && attr.fg) out.push([attr.fg, o.foreground]);
|
||||
if (isHexColor(o.background) && attr.bg) out.push([attr.bg, o.background]);
|
||||
if (isHexColor(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]);
|
||||
if (attr.effect && isHexColor(o.effectColor)) {
|
||||
if (attr.id === "error") out.push(["--editor-error-decoration", `underline wavy ${o.effectColor}`]);
|
||||
if (attr.id === "warning") out.push(["--editor-warning-decoration", `underline wavy ${o.effectColor}`]);
|
||||
}
|
||||
@@ -303,13 +304,58 @@ export function overrideDeclarations(
|
||||
out.push([`--syn-${t}-italic`, o.italic ? "italic" : "normal"]);
|
||||
if (t === "comment") out.push(["--syn-comment-style", o.italic ? "italic" : "normal"]);
|
||||
}
|
||||
if (o.background) out.push([`--syn-${t}-bg`, o.background]);
|
||||
if (o.effect) out.push([`--syn-${t}-effect`, EFFECT_CSS[o.effect]?.(o.effectColor ?? "currentColor") ?? "none"]);
|
||||
if (isHexColor(o.background)) out.push([`--syn-${t}-bg`, o.background]);
|
||||
if (o.effect)
|
||||
out.push([
|
||||
`--syn-${t}-effect`,
|
||||
EFFECT_CSS[o.effect]?.(isHexColor(o.effectColor) ? o.effectColor : "currentColor") ?? "none",
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const isHexColor = (value: unknown): value is string =>
|
||||
typeof value === "string" && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value);
|
||||
|
||||
/** Validate persisted imports too: settings files are user editable. */
|
||||
export function customSchemes(raw: unknown): CustomColorSchemes {
|
||||
const out: CustomColorSchemes = {};
|
||||
if (!raw || typeof raw !== "object") return out;
|
||||
for (const [id, value] of Object.entries(raw)) {
|
||||
if (!/^custom-[\w-]+$/.test(id) || !value || typeof value !== "object") continue;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (typeof v.name !== "string" || !v.name.trim() || (v.parent !== "light" && v.parent !== "dark")) continue;
|
||||
const attributes: Record<string, TextAttributesOverride> = {};
|
||||
if (v.attributes && typeof v.attributes === "object") {
|
||||
for (const attr of ATTRIBUTES) {
|
||||
const a = (v.attributes as Record<string, TextAttributesOverride>)[attr.id];
|
||||
if (!a || typeof a !== "object") continue;
|
||||
const safe: TextAttributesOverride = {};
|
||||
for (const key of ["foreground", "background", "effectColor"] as const)
|
||||
if (isHexColor(a[key])) safe[key] = a[key];
|
||||
for (const key of ["bold", "italic"] as const) if (typeof a[key] === "boolean") safe[key] = a[key];
|
||||
if (a.effect && Object.hasOwn(EFFECT_CSS, a.effect)) safe.effect = a.effect;
|
||||
attributes[attr.id] = safe;
|
||||
}
|
||||
}
|
||||
out[id] = { name: v.name, parent: v.parent, attributes };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listSchemes(customs: CustomColorSchemes = {}) {
|
||||
return [...SCHEMES, ...Object.entries(customSchemes(customs)).map(([id, s]) => ({ id, name: s.name }))];
|
||||
}
|
||||
|
||||
export function resolvedSchemeTokens(id: string, customs: CustomColorSchemes = {}): Vars {
|
||||
const custom = customSchemes(customs)[id];
|
||||
const base = isBuiltinTheme(id) ? id : (custom?.parent ?? "light");
|
||||
const tokens = new Map(schemeTokens(base));
|
||||
for (const [key, value] of overrideDeclarations(custom?.attributes)) tokens.set(key, value);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const SAFE = /^[^;{}<>]*$/;
|
||||
|
||||
/** Stylesheet text for the chosen scheme and its overrides ("" when the theme's own is used unchanged). */
|
||||
@@ -317,14 +363,16 @@ export function schemeCss(
|
||||
setting: string | undefined,
|
||||
overrides: Record<string, Record<string, TextAttributesOverride>> | undefined,
|
||||
root?: HTMLElement | null,
|
||||
customs: CustomColorSchemes = {},
|
||||
): string {
|
||||
const id = effectiveScheme(setting, root);
|
||||
const valid = customSchemes(customs);
|
||||
const id = effectiveScheme(setting, root, valid);
|
||||
const decls: Array<[string, string]> = [];
|
||||
// A plugin theme brings its own editor colours: an explicit scheme replaces them even on the same base.
|
||||
const el = root === undefined ? (typeof document !== "undefined" ? document.documentElement : null) : root;
|
||||
const pluginTheme = !!el?.dataset.pluginTheme && id === setting;
|
||||
if (id !== themeScheme(root) || pluginTheme) {
|
||||
const tokens = schemeTokens(id);
|
||||
const tokens = resolvedSchemeTokens(id, valid);
|
||||
for (const [k, v] of tokens) if (SCHEME_VARS.has(k) || k.startsWith("--syn-")) decls.push([k, v]);
|
||||
// The editor surface follows the scheme, not the island colour of the UI theme.
|
||||
const bg = tokens.get("--bg-editor") ?? tokens.get("--bg-island");
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface TextAttributesOverride {
|
||||
effectColor?: string;
|
||||
}
|
||||
|
||||
export interface CustomColorScheme {
|
||||
name: string;
|
||||
parent: "dark" | "light";
|
||||
attributes: Record<string, TextAttributesOverride>;
|
||||
}
|
||||
|
||||
export type CustomColorSchemes = Record<string, CustomColorScheme>;
|
||||
|
||||
export interface LanguageCodeStyle {
|
||||
useTab?: boolean;
|
||||
tabSize?: number;
|
||||
@@ -55,6 +63,7 @@ export function lineCommentStyleFor(
|
||||
}
|
||||
|
||||
export const EDITOR_EXTRA_DEFAULTS = {
|
||||
customSchemes: {} as CustomColorSchemes,
|
||||
colorOverrides: {} as Record<string, Record<string, TextAttributesOverride>>,
|
||||
/** Color Scheme › Language Defaults › Semantic highlighting (rainbow locals / parameters). */
|
||||
semanticHighlighting: false,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { _setSchemeSource, customSchemes, effectiveScheme, listSchemes, SCHEMES, schemeCss } from "./colorScheme";
|
||||
import { deleteScheme, exportIcls, importScheme, parseIcls } from "./icls";
|
||||
import { validateSettings } from "@/features/settings/model/schema";
|
||||
import { colorSchemeItems } from "@/workbench/quickSwitch";
|
||||
import { useProject } from "./project";
|
||||
import type { Settings } from "./ipc";
|
||||
import { isClientSetting } from "./clientSettings";
|
||||
|
||||
const fixture = (name: string) => readFileSync(`src/platform/__fixtures__/colorSchemes/${name}.icls`, "utf8");
|
||||
beforeAll(() => _setSchemeSource(readFileSync("src/styles/tokens.css", "utf8")));
|
||||
|
||||
describe(".icls schemes", () => {
|
||||
it("maps defaults, Java/Kotlin, editor surfaces, search and diagnostics with counts", () => {
|
||||
const { scheme, applied, skipped } = parseIcls(fixture("forest"));
|
||||
expect(scheme.name).toBe("Forest & Moss");
|
||||
expect(scheme.parent).toBe("dark");
|
||||
expect(scheme.attributes.keyword).toEqual({ foreground: "#99dd77", bold: true, italic: false });
|
||||
expect(scheme.attributes.functionDeclaration).toEqual({ foreground: "#e0c080", bold: false, italic: true });
|
||||
expect(scheme.attributes.text).toEqual({ foreground: "#d8ebd8", background: "#18281e" });
|
||||
expect(scheme.attributes.caret?.foreground).toBe("#f0e080");
|
||||
expect(scheme.attributes.selection?.background).toBe("#406040");
|
||||
expect(scheme.attributes.gutter?.background).toBe("#142018");
|
||||
expect(scheme.attributes.searchResult?.background).toBe("#6a5530");
|
||||
expect(scheme.attributes.error).toEqual({ effect: "wave", effectColor: "#ff6655" });
|
||||
expect([applied, skipped]).toEqual([16, 2]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"<scheme",
|
||||
'<scheme name="A"><attributes></scheme>',
|
||||
"<scheme/>",
|
||||
'<html name="x"/>',
|
||||
'<!DOCTYPE scheme [<!ENTITY a "red">]><scheme name="x"/>',
|
||||
])("rejects malformed XML without mutating schemes: %s", (xml) => {
|
||||
const customs = importScheme({}, parseIcls(fixture("forest")).scheme).customs;
|
||||
const before = structuredClone(customs);
|
||||
expect(() => parseIcls(xml)).toThrow("Invalid .icls color scheme");
|
||||
expect(customs).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Darcula", "ffffff", "dark"],
|
||||
["Default", "000000", "light"],
|
||||
["Other", "101820", "dark"],
|
||||
["Other", "fefefe", "light"],
|
||||
["", "0", "dark"],
|
||||
])("maps parent %s and background %s to %s", (parent, bg, expected) => {
|
||||
expect(
|
||||
parseIcls(
|
||||
`<scheme name="Test" parent_scheme="${parent}"><attributes><option name="TEXT"><value><option name="BACKGROUND" value="${bg}"/></value></option></attributes></scheme>`,
|
||||
).scheme.parent,
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("ignores CSS injection on import and from edited settings", () => {
|
||||
const { scheme, applied, skipped } = parseIcls(
|
||||
'<scheme name="evil"><colors><option name="CARET_COLOR" value="fff;--bad:red"/></colors><attributes><option name="TEXT"><value><option name="BACKGROUND" value="fff}body{display:none"/></value></option></attributes></scheme>',
|
||||
);
|
||||
expect([applied, skipped]).toEqual([0, 2]);
|
||||
expect(scheme.attributes).toEqual({});
|
||||
const customs = {
|
||||
"custom-test": {
|
||||
name: "Test",
|
||||
parent: "dark",
|
||||
attributes: {
|
||||
keyword: { foreground: "red", background: "url(https://evil)", effectColor: "var(--bad)", effect: "wave" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const valid = customSchemes(customs);
|
||||
expect(valid["custom-test"]?.attributes.keyword).toEqual({ effect: "wave" });
|
||||
const css = schemeCss("custom-test", undefined, null, valid);
|
||||
expect(css).not.toMatch(/evil|url\(|--bad/);
|
||||
expect(schemeCss("dark", { dark: { keyword: { foreground: "url(https://evil)" } } }, null)).not.toContain("evil");
|
||||
});
|
||||
|
||||
it("keeps both, overwrites a custom, and never overwrites a built-in", () => {
|
||||
const parsed = parseIcls(fixture("forest")).scheme;
|
||||
const first = importScheme({}, parsed);
|
||||
const both = importScheme(first.customs, parsed);
|
||||
expect(both.customs[both.id]?.name).toBe("Forest & Moss (2)");
|
||||
const third = importScheme(both.customs, parsed);
|
||||
expect(third.customs[third.id]?.name).toBe("Forest & Moss (3)");
|
||||
const overwrite = importScheme(both.customs, { ...parsed, parent: "light" }, true);
|
||||
expect(overwrite.id).toBe(first.id);
|
||||
expect(overwrite.customs[first.id]?.parent).toBe("light");
|
||||
const builtin = importScheme({}, { ...parsed, name: SCHEMES[0]!.name }, true);
|
||||
expect(builtin.customs[builtin.id]?.name).toBe(`${SCHEMES[0]!.name} (2)`);
|
||||
expect(SCHEMES.some((s) => s.id === builtin.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses imported values under either theme, overrides them, and falls back after deletion", () => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
|
||||
const overrides = { [id]: { keyword: { foreground: "#abcdef" } } };
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const root = document.createElement("div");
|
||||
root.dataset.theme = theme;
|
||||
const css = schemeCss(id, overrides, root, customs);
|
||||
expect(css).toContain("--bg-editor: #18281e !important");
|
||||
expect(css.lastIndexOf("--syn-keyword: #abcdef")).toBeGreaterThan(css.indexOf("--syn-keyword: #99dd77"));
|
||||
expect(effectiveScheme("theme-default", root, customs)).toBe(theme);
|
||||
const deleted = deleteScheme(customs, overrides, id, id);
|
||||
expect(deleted).toEqual({ customs: {}, overrides: {}, selected: "theme-default" });
|
||||
expect(effectiveScheme(id, root, deleted.customs)).toBe(theme);
|
||||
expect(schemeCss(deleted.selected, deleted.overrides, root, deleted.customs)).toBe("");
|
||||
expect(deleteScheme(customs, overrides, "light", id).selected).toBe("light");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["forest", "daylight"])("exports visible values and re-imports %s with overrides", (name) => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture(name)).scheme);
|
||||
const override = {
|
||||
keyword: { foreground: "#123456", bold: false, italic: true },
|
||||
error: { effectColor: "#aa1122" },
|
||||
};
|
||||
const xml = exportIcls(id, customs, override);
|
||||
const imported = parseIcls(xml).scheme;
|
||||
expect(imported.name).toBe(customs[id]!.name);
|
||||
expect(imported.parent).toBe(customs[id]!.parent);
|
||||
expect(imported.attributes.keyword).toEqual(override.keyword);
|
||||
expect(imported.attributes.text).toEqual(customs[id]!.attributes.text);
|
||||
expect(imported.attributes.error?.effectColor).toBe("#aa1122");
|
||||
});
|
||||
|
||||
it("exports shorthand colors as full RGB", () => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
|
||||
expect(
|
||||
parseIcls(exportIcls(id, customs, { keyword: { foreground: "#abc" } })).scheme.attributes.keyword?.foreground,
|
||||
).toBe("#aabbcc");
|
||||
});
|
||||
|
||||
it("round trips custom settings and lists them without changing built-ins", () => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
|
||||
const settings = validateSettings(
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
editor: {
|
||||
customSchemes: customs,
|
||||
colorScheme: id,
|
||||
colorOverrides: { [id]: { string: { foreground: "#123456" } } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(settings.editor.customSchemes).toEqual(customs);
|
||||
expect(settings.editor.colorScheme).toBe(id);
|
||||
expect(listSchemes(customs).at(-1)?.name).toBe("Forest & Moss");
|
||||
expect(listSchemes(customs)).toHaveLength(SCHEMES.length + 1);
|
||||
expect(isClientSetting("editor.customSchemes")).toBe(true);
|
||||
const previous = useProject.getState().settings;
|
||||
try {
|
||||
useProject.setState({ settings: settings as Settings });
|
||||
const items = colorSchemeItems(id);
|
||||
expect(items.find((s) => "id" in s && s.id === `scheme.${id}`)).toMatchObject({
|
||||
label: "Forest & Moss",
|
||||
checked: true,
|
||||
});
|
||||
expect(items.find((s) => "id" in s && s.id === "scheme.theme-default")).toMatchObject({ checked: false });
|
||||
} finally {
|
||||
useProject.setState({ settings: previous });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/** IntelliJ editor schemes. Unsupported keys are counted, never copied into CSS. */
|
||||
import { ATTRIBUTES, customSchemes, isHexColor, listSchemes, resolvedSchemeTokens } from "./colorScheme";
|
||||
import { isBuiltinTheme, themeKind } from "./themes";
|
||||
import type { CustomColorScheme, CustomColorSchemes, TextAttributesOverride } from "./editorSettings";
|
||||
|
||||
// Many language keys share the editor's language-default tokens.
|
||||
const ATTRIBUTE_NAMES: Record<string, string> = {
|
||||
TEXT: "text",
|
||||
DEFAULT_TEXT: "text",
|
||||
DEFAULT_KEYWORD: "keyword",
|
||||
DEFAULT_IDENTIFIER: "identifier",
|
||||
DEFAULT_LOCAL_VARIABLE: "identifier",
|
||||
DEFAULT_PARAMETER: "parameter",
|
||||
DEFAULT_CONSTANT: "constant",
|
||||
DEFAULT_INSTANCE_FIELD: "field",
|
||||
DEFAULT_STATIC_FIELD: "constant",
|
||||
DEFAULT_FUNCTION_DECLARATION: "functionDeclaration",
|
||||
DEFAULT_FUNCTION_CALL: "functionCall",
|
||||
DEFAULT_PREDEFINED_SYMBOL: "predefined",
|
||||
DEFAULT_CLASS_NAME: "className",
|
||||
DEFAULT_INTERFACE_NAME: "className",
|
||||
DEFAULT_NUMBER: "number",
|
||||
DEFAULT_STRING: "string",
|
||||
DEFAULT_VALID_STRING_ESCAPE: "escape",
|
||||
DEFAULT_INVALID_STRING_ESCAPE: "invalid",
|
||||
DEFAULT_LINE_COMMENT: "lineComment",
|
||||
DEFAULT_BLOCK_COMMENT: "lineComment",
|
||||
DEFAULT_DOC_COMMENT: "docComment",
|
||||
DEFAULT_METADATA: "metadata",
|
||||
DEFAULT_OPERATION_SIGN: "operator",
|
||||
DEFAULT_BRACES: "operator",
|
||||
DEFAULT_BRACKETS: "operator",
|
||||
DEFAULT_PARENTHS: "operator",
|
||||
DEFAULT_DOT: "operator",
|
||||
DEFAULT_COMMA: "operator",
|
||||
DEFAULT_SEMICOLON: "operator",
|
||||
DEFAULT_MARKUP_TAG: "markupTag",
|
||||
DEFAULT_MARKUP_ATTRIBUTE: "markupAttribute",
|
||||
BAD_CHARACTER: "invalid",
|
||||
TODO_DEFAULT_ATTRIBUTES: "todo",
|
||||
SEARCH_RESULT_ATTRIBUTES: "searchResult",
|
||||
TEXT_SEARCH_RESULT_ATTRIBUTES: "searchResultCurrent",
|
||||
WRITE_SEARCH_RESULT_ATTRIBUTES: "identifierUnderCaretWrite",
|
||||
IDENTIFIER_UNDER_CARET_ATTRIBUTES: "identifierUnderCaret",
|
||||
MATCHED_BRACE_ATTRIBUTES: "matchedBrace",
|
||||
ERRORS_ATTRIBUTES: "error",
|
||||
WARNING_ATTRIBUTES: "warning",
|
||||
WEAK_WARNING_ATTRIBUTES: "weakWarning",
|
||||
NOT_USED_ELEMENT_ATTRIBUTES: "unused",
|
||||
FOLDED_TEXT_ATTRIBUTES: "folded",
|
||||
INLAY_DEFAULT: "inlay",
|
||||
PY_SELF_PARAMETER: "pySelf",
|
||||
PY_KEYWORD_ARGUMENT: "pyKeywordArg",
|
||||
PY_DOC_STRING: "pyDocstring",
|
||||
PY_BUILTIN_NAME: "pyBuiltin",
|
||||
PY_DECORATOR: "pyDecorator",
|
||||
MARKDOWN_HEADER_LEVEL_1: "mdHeading",
|
||||
MARKDOWN_CODE_SPAN: "mdCode",
|
||||
MARKDOWN_LINK_TEXT: "mdLink",
|
||||
REGEXP: "regexp",
|
||||
};
|
||||
const LANGUAGE_KEYS: Record<string, string> = {
|
||||
KEYWORD: "keyword",
|
||||
NUMBER: "number",
|
||||
STRING: "string",
|
||||
STRING_TEXT: "string",
|
||||
VALID_STRING_ESCAPE: "escape",
|
||||
LINE_COMMENT: "lineComment",
|
||||
BLOCK_COMMENT: "lineComment",
|
||||
DOC_COMMENT: "docComment",
|
||||
CLASS: "className",
|
||||
CLASS_NAME: "className",
|
||||
INTERFACE: "className",
|
||||
ENUM: "className",
|
||||
TYPE_PARAMETER: "className",
|
||||
ANNOTATION_NAME: "metadata",
|
||||
ANNOTATION: "metadata",
|
||||
LOCAL_VARIABLE: "identifier",
|
||||
PARAMETER: "parameter",
|
||||
INSTANCE_FIELD: "field",
|
||||
STATIC_FIELD: "constant",
|
||||
STATIC_FINAL_FIELD: "constant",
|
||||
PROPERTY: "field",
|
||||
BACKING_FIELD_VARIABLE: "field",
|
||||
FUNCTION_DECLARATION: "functionDeclaration",
|
||||
METHOD_DECLARATION: "functionDeclaration",
|
||||
FUNCTION_CALL: "functionCall",
|
||||
METHOD_CALL: "functionCall",
|
||||
STATIC_METHOD: "functionCall",
|
||||
CONSTRUCTOR_CALL: "className",
|
||||
OPERATION_SIGN: "operator",
|
||||
BRACES: "operator",
|
||||
BRACKETS: "operator",
|
||||
PARENTH: "operator",
|
||||
PARENTHS: "operator",
|
||||
};
|
||||
for (const [name, id] of Object.entries(LANGUAGE_KEYS)) {
|
||||
ATTRIBUTE_NAMES[`JAVA_${name}`] = id;
|
||||
ATTRIBUTE_NAMES[`KOTLIN_${name}`] = id;
|
||||
ATTRIBUTE_NAMES[name] = id;
|
||||
}
|
||||
Object.assign(ATTRIBUTE_NAMES, {
|
||||
KOTLIN_MUTABLE_VARIABLE: "identifier",
|
||||
KOTLIN_PACKAGE_PROPERTY: "field",
|
||||
KOTLIN_SYNTHETIC_EXTENSION_PROPERTY: "field",
|
||||
KOTLIN_EXTENSION_FUNCTION_CALL: "functionCall",
|
||||
KOTLIN_NAMED_ARGUMENT: "parameter",
|
||||
KOTLIN_OBJECT: "className",
|
||||
});
|
||||
|
||||
const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
|
||||
CARET_COLOR: ["caret", "foreground"],
|
||||
CARET_ROW_COLOR: ["caretRow", "background"],
|
||||
SELECTION_BACKGROUND: ["selection", "background"],
|
||||
INACTIVE_SELECTION_BACKGROUND: ["selectionInactive", "background"],
|
||||
GUTTER_BACKGROUND: ["gutter", "background"],
|
||||
LINE_NUMBERS_COLOR: ["lineNumber", "foreground"],
|
||||
LINE_NUMBER_ON_CARET_ROW_COLOR: ["lineNumberCaret", "foreground"],
|
||||
WHITESPACES: ["whitespaces", "foreground"],
|
||||
INDENT_GUIDE: ["indentGuide", "foreground"],
|
||||
RIGHT_MARGIN_COLOR: ["rightMargin", "foreground"],
|
||||
METHOD_SEPARATORS_COLOR: ["methodSeparator", "foreground"],
|
||||
};
|
||||
// Preserve editor attributes without a standard exchange name on our own round trip.
|
||||
for (const attr of ATTRIBUTES)
|
||||
if (!Object.values(ATTRIBUTE_NAMES).includes(attr.id)) ATTRIBUTE_NAMES[`OPENIDEA_${attr.id}`] = attr.id;
|
||||
|
||||
const EFFECTS: Record<string, TextAttributesOverride["effect"]> = {
|
||||
"0": "underline",
|
||||
"1": "wave",
|
||||
"3": "strikeout",
|
||||
"4": "underline",
|
||||
};
|
||||
const children = (el: Element, tag: string) => Array.from(el.children).filter((e) => e.tagName === tag);
|
||||
const color = (v: string | null): string | undefined =>
|
||||
v && /^[0-9a-f]{1,6}$/i.test(v) ? `#${v.padStart(6, "0").toLowerCase()}` : undefined;
|
||||
|
||||
export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: number; skipped: number } {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
const root = doc.documentElement;
|
||||
if (
|
||||
doc.querySelector("parsererror") ||
|
||||
doc.doctype ||
|
||||
root.tagName !== "scheme" ||
|
||||
!root.getAttribute("name")?.trim()
|
||||
)
|
||||
throw new Error("Invalid .icls color scheme");
|
||||
const attributes: Record<string, TextAttributesOverride> = {};
|
||||
let applied = 0;
|
||||
let skipped = 0;
|
||||
const merge = (id: string, value: TextAttributesOverride) => {
|
||||
attributes[id] = { ...attributes[id], ...value };
|
||||
};
|
||||
for (const section of Array.from(root.children)) {
|
||||
if (section.tagName === "colors")
|
||||
for (const entry of children(section, "option")) {
|
||||
const name = entry.getAttribute("name") ?? "";
|
||||
const target = Object.hasOwn(COLOR_NAMES, name) ? COLOR_NAMES[name] : undefined;
|
||||
const c = color(entry.getAttribute("value"));
|
||||
if (target && c) {
|
||||
merge(target[0], { [target[1]]: c });
|
||||
applied++;
|
||||
} else skipped++;
|
||||
}
|
||||
if (section.tagName === "attributes")
|
||||
for (const entry of children(section, "option")) {
|
||||
const name = entry.getAttribute("name") ?? "";
|
||||
const id = Object.hasOwn(ATTRIBUTE_NAMES, name) ? ATTRIBUTE_NAMES[name] : undefined;
|
||||
if (!id) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const value = children(entry, "value")[0];
|
||||
if (!value) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
for (const option of children(value, "option")) {
|
||||
const name = option.getAttribute("name");
|
||||
const raw = option.getAttribute("value");
|
||||
const c = color(raw);
|
||||
const field =
|
||||
name === "FOREGROUND"
|
||||
? "foreground"
|
||||
: name === "BACKGROUND"
|
||||
? "background"
|
||||
: name === "EFFECT_COLOR"
|
||||
? "effectColor"
|
||||
: undefined;
|
||||
const attr = ATTRIBUTES.find((a) => a.id === id)!;
|
||||
const supported =
|
||||
field === "foreground"
|
||||
? !!attr.fg
|
||||
: field === "background"
|
||||
? !!attr.bg || !!attr.token
|
||||
: !!attr.effect || !!attr.token;
|
||||
if (field && c && supported) {
|
||||
merge(id, { [field]: c });
|
||||
applied++;
|
||||
} else if (name === "FONT_TYPE" && attr.token && raw && /^[0-3]$/.test(raw)) {
|
||||
merge(id, { bold: !!(Number(raw) & 1), italic: !!(Number(raw) & 2) });
|
||||
applied++;
|
||||
} else if (name === "EFFECT_TYPE" && (attr.token || attr.effect) && raw && Object.hasOwn(EFFECTS, raw)) {
|
||||
merge(id, { effect: EFFECTS[raw] });
|
||||
applied++;
|
||||
} else skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const parentName = root.getAttribute("parent_scheme") ?? "";
|
||||
const bg = attributes.text?.background;
|
||||
const rgb = bg ? [1, 3, 5].map((i) => parseInt(bg.slice(i, i + 2), 16)) : [255, 255, 255];
|
||||
const dark = rgb[0]! * 0.299 + rgb[1]! * 0.587 + rgb[2]! * 0.114 < 128;
|
||||
const parent = /darcula|dark/i.test(parentName)
|
||||
? "dark"
|
||||
: /default|light/i.test(parentName)
|
||||
? "light"
|
||||
: dark
|
||||
? "dark"
|
||||
: "light";
|
||||
return { scheme: { name: root.getAttribute("name")!.trim(), parent, attributes }, applied, skipped };
|
||||
}
|
||||
|
||||
/** Name collisions include built-ins; imports never replace a built-in. */
|
||||
export function importScheme(customs: CustomColorSchemes, scheme: CustomColorScheme, overwrite = false) {
|
||||
const existing = Object.entries(customs).find(([, s]) => s.name === scheme.name);
|
||||
const names = new Set(listSchemes(customs).map((s) => s.name));
|
||||
let name = scheme.name;
|
||||
if (!(overwrite && existing)) for (let i = 2; names.has(name); i++) name = `${scheme.name} (${i})`;
|
||||
const id = overwrite && existing ? existing[0] : `custom-${crypto.randomUUID()}`;
|
||||
return { id, customs: { ...customs, [id]: { ...scheme, name } } };
|
||||
}
|
||||
|
||||
export function deleteScheme(
|
||||
customs: CustomColorSchemes,
|
||||
overrides: Record<string, Record<string, TextAttributesOverride>>,
|
||||
selected: string,
|
||||
id: string,
|
||||
) {
|
||||
return {
|
||||
customs: Object.fromEntries(Object.entries(customs).filter(([key]) => key !== id)),
|
||||
overrides: Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== id)),
|
||||
selected: selected === id ? "theme-default" : selected,
|
||||
};
|
||||
}
|
||||
|
||||
const exportColor = (c: string) =>
|
||||
c.length === 4
|
||||
? c
|
||||
.slice(1)
|
||||
.split("")
|
||||
.map((ch) => ch + ch)
|
||||
.join("")
|
||||
: c.slice(1);
|
||||
const escapeXml = (v: string) =>
|
||||
v.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """).replace(/>/g, ">");
|
||||
export function exportIcls(
|
||||
id: string,
|
||||
customs: CustomColorSchemes,
|
||||
overrides: Record<string, TextAttributesOverride> = {},
|
||||
): string {
|
||||
const custom = customSchemes(customs)[id];
|
||||
const name = listSchemes(customs).find((s) => s.id === id)?.name ?? id;
|
||||
const tokens = resolvedSchemeTokens(id, customs);
|
||||
const values = (attr: (typeof ATTRIBUTES)[number]): TextAttributesOverride => ({
|
||||
...(attr.token
|
||||
? {
|
||||
bold: /bold|[6-9]00/.test(tokens.get(`--syn-${attr.token}-bold`) ?? ""),
|
||||
italic:
|
||||
(tokens.get(`--syn-${attr.token}-italic`) ??
|
||||
(attr.token === "comment"
|
||||
? tokens.get("--syn-comment-style")
|
||||
: (attr.italicOnly && attr.defaultItalic !== false) || ["doc-comment", "docstring"].includes(attr.token)
|
||||
? "italic"
|
||||
: "normal")) === "italic",
|
||||
}
|
||||
: {}),
|
||||
...(attr.fg && isHexColor(tokens.get(attr.fg)) ? { foreground: tokens.get(attr.fg) } : {}),
|
||||
...(attr.bg && isHexColor(tokens.get(attr.bg)) ? { background: tokens.get(attr.bg) } : {}),
|
||||
...(attr.effect && isHexColor(tokens.get(attr.effect)) ? { effectColor: tokens.get(attr.effect) } : {}),
|
||||
...custom?.attributes[attr.id],
|
||||
...overrides[attr.id],
|
||||
});
|
||||
const colors: string[] = [];
|
||||
const attributes: string[] = [];
|
||||
for (const [key, [id, field]] of Object.entries(COLOR_NAMES)) {
|
||||
const attr = ATTRIBUTES.find((a) => a.id === id);
|
||||
const c = attr && values(attr)[field];
|
||||
if (isHexColor(c)) colors.push(` <option name="${key}" value="${exportColor(c)}" />`);
|
||||
}
|
||||
for (const attr of ATTRIBUTES) {
|
||||
const key = Object.keys(ATTRIBUTE_NAMES).find((key) => ATTRIBUTE_NAMES[key] === attr.id);
|
||||
if (!key) continue;
|
||||
const v = values(attr);
|
||||
const opts: string[] = [];
|
||||
for (const [key, field] of [
|
||||
["FOREGROUND", "foreground"],
|
||||
["BACKGROUND", "background"],
|
||||
["EFFECT_COLOR", "effectColor"],
|
||||
] as const)
|
||||
if (isHexColor(v[field])) opts.push(`<option name="${key}" value="${exportColor(v[field]!)}" />`);
|
||||
if (attr.token) opts.push(`<option name="FONT_TYPE" value="${(v.bold ? 1 : 0) + (v.italic ? 2 : 0)}" />`);
|
||||
const effect = Object.entries(EFFECTS).find(([, value]) => value === v.effect)?.[0];
|
||||
if (effect) opts.push(`<option name="EFFECT_TYPE" value="${effect}" />`);
|
||||
if (opts.length) attributes.push(` <option name="${key}"><value>${opts.join("")}</value></option>`);
|
||||
}
|
||||
const parent = custom?.parent ?? (isBuiltinTheme(id) ? themeKind(id) : "dark");
|
||||
return `<scheme name="${escapeXml(name)}" version="142" parent_scheme="${parent === "dark" ? "Darcula" : "Default"}">\n <colors>\n${colors.join("\n")}\n </colors>\n <attributes>\n${attributes.join("\n")}\n </attributes>\n</scheme>\n`;
|
||||
}
|
||||
@@ -5228,5 +5228,16 @@
|
||||
"For clipboard images: {timestamp} becomes YYYYMMDD-HHmmss. Source file names are preserved.": "剪贴板图片中的 {timestamp} 替换为 YYYYMMDD-HHmmss。保留源文件名称。",
|
||||
"Image folder must be relative.": "图片文件夹必须使用相对路径。",
|
||||
"Image exceeds 16 MB.": "图片超过 16 MB。",
|
||||
"Cannot insert image": "无法插入图片"
|
||||
"Cannot insert image": "无法插入图片",
|
||||
"Import Scheme": "导入配色方案",
|
||||
"Export Scheme": "导出配色方案",
|
||||
"Delete Scheme": "删除配色方案",
|
||||
"Keep Both": "保留两个",
|
||||
"Scheme exported.": "配色方案已导出。",
|
||||
"Invalid .icls color scheme": "无效的 .icls 配色方案",
|
||||
"IntelliJ Color Scheme": "IntelliJ 配色方案",
|
||||
"A scheme named '{name}' already exists.": "名为“{name}”的配色方案已存在。",
|
||||
"Applied {applied} values; skipped {skipped} unsupported or invalid values.": "已应用 {applied} 个值;跳过了 {skipped} 个不支持或无效的值。",
|
||||
"Delete color scheme '{name}'?": "删除配色方案“{name}”?",
|
||||
"Overwrite": "覆盖"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* toolbar's gear menu and View › Quick Switch Scheme… (⌃`).
|
||||
*/
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { SCHEMES } from "@/platform/colorScheme";
|
||||
import { listSchemes } from "@/platform/colorScheme";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { PRESET_TITLES, useKeymap } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { MenuItem } from "@/ui";
|
||||
@@ -40,7 +41,8 @@ export function keymapItems(keymapId: string, customKeymaps: string[]): MenuItem
|
||||
}
|
||||
|
||||
export function colorSchemeItems(current: string | undefined): MenuItem[] {
|
||||
const value = current && SCHEMES.some((s) => s.id === current) ? current : "theme-default";
|
||||
const schemes = listSchemes(editorPrefs().customSchemes);
|
||||
const value = current && schemes.some((s) => s.id === current) ? current : "theme-default";
|
||||
return [
|
||||
{
|
||||
id: "scheme.theme-default",
|
||||
@@ -48,7 +50,7 @@ export function colorSchemeItems(current: string | undefined): MenuItem[] {
|
||||
checked: value === "theme-default",
|
||||
run: () => void setGlobal("editor.colorScheme", "theme-default", "color scheme"),
|
||||
},
|
||||
...SCHEMES.map<MenuItem>((s) => ({
|
||||
...schemes.map<MenuItem>((s) => ({
|
||||
id: `scheme.${s.id}`,
|
||||
label: s.name,
|
||||
checked: s.id === value,
|
||||
|
||||
Reference in New Issue
Block a user