дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02: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
Цей коміт міститься в:
1 джерело
92cbdd0cb8
коміт
bde19959b9
17 files changed
+950
-57
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Заблокувати користувача