feat(settings): import JetBrains settings.zip (colour schemes, editor font and options) (#1709)

Import Settings… also accepts a JetBrains settings.zip: colour schemes (and the current one), editor font, size, line spacing and ligatures, trailing-space stripping, final newline, sticky lines, whitespace, soft wraps and confirm-on-exit are mapped after a confirmation; keymaps, code styles, live templates, inspections and plugins are listed as not imported.

Refs #656
Цей коміт міститься в:
LU XIANGRUI authored and GitHub committed 2026-11-07 21:13:48 +08:00
1 джерело 309bb48f2c
коміт 1ab4edc0d7
11 files changed
+461 -2

No files matched your search

+1 -1
Переглянути файл
@@ -22,8 +22,8 @@ tracing.workspace = true
tempfile.workspace = true
globset.workspace = true
uuid.workspace = true
zip.workspace = true
[dev-dependencies]
tempfile.workspace = true
encoding_rs.workspace = true
zip.workspace = true
+74
Переглянути файл
@@ -316,6 +316,48 @@ impl SettingsStore {
}
}
/// A JetBrains IDE's File › Manage IDE Settings › Export Settings… archive: every
/// entry name, and the text of the entries OpenIDEA maps (the frontend does the mapping).
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct JetBrainsSettings {
pub entries: Vec<String>,
/// Entry name → text, for `colors/*.icls` and the `options/*.xml` files OpenIDEA reads.
pub files: BTreeMap<String, String>,
}
const JETBRAINS_MARKER: &str = "IntelliJ IDEA Global Settings";
const JETBRAINS_OPTIONS: [&str; 4] =
["options/editor-font.xml", "options/colors.scheme.xml", "options/editor.xml", "options/ide.general.xml"];
const JETBRAINS_MAX_TEXT: u64 = 4 << 20;
pub fn read_jetbrains_settings(path: &Path) -> IdeResult<JetBrainsSettings> {
use std::io::Read;
let not_jetbrains = || IdeError::invalid("not a JetBrains settings archive");
let mut archive = zip::ZipArchive::new(std::fs::File::open(path)?).map_err(|_| not_jetbrains())?;
let mut entries = Vec::with_capacity(archive.len());
let mut files = BTreeMap::new();
for i in 0..archive.len() {
let entry = archive.by_index(i).map_err(|_| not_jetbrains())?;
let name = entry.name().map_err(|_| not_jetbrains())?.into_owned();
let wanted = JETBRAINS_OPTIONS.contains(&name.as_str())
|| name.strip_prefix("colors/").is_some_and(|n| !n.contains('/') && n.ends_with(".icls"));
if wanted {
let mut text = String::new();
entry
.take(JETBRAINS_MAX_TEXT)
.read_to_string(&mut text)
.map_err(|e| IdeError::invalid(format!("{name}: {e}")))?;
files.insert(name.clone(), text);
}
entries.push(name);
}
if !entries.iter().any(|e| e == JETBRAINS_MARKER) {
return Err(not_jetbrains());
}
Ok(JetBrainsSettings { entries, files })
}
const BUNDLE_FORMAT: &str = "openidea-settings";
const BUNDLE_VERSION: u32 = 1;
@@ -546,6 +588,38 @@ mod tests {
assert_eq!(target.effective().editor.font_size, 17.0);
}
#[test]
fn reads_jetbrains_settings_archives() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let write = |name: &str, entries: &[(&str, &str)]| {
let path = tmp.path().join(name);
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
for (entry, text) in entries {
zip.start_file(*entry, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
path
};
let ok = write(
"settings.zip",
&[
(JETBRAINS_MARKER, ""),
("colors/_@user_Darcula.icls", "<scheme name=\"_@user_Darcula\"/>"),
("options/editor-font.xml", "<application/>"),
("options/vcs.xml", "<application/>"),
("keymaps/macOS.xml", "<keymap/>"),
],
);
let read = read_jetbrains_settings(&ok).unwrap();
assert_eq!(read.entries.len(), 5);
assert_eq!(read.files.keys().collect::<Vec<_>>(), ["colors/_@user_Darcula.icls", "options/editor-font.xml"]);
assert!(read_jetbrains_settings(&write("other.zip", &[("options/editor.xml", "")])).is_err());
std::fs::write(tmp.path().join("plain.zip"), "not a zip").unwrap();
assert!(read_jetbrains_settings(&tmp.path().join("plain.zip")).is_err());
}
#[test]
fn main_toolbar_can_be_set_and_reset() {
let tmp = tempfile::tempdir().unwrap();
+9
Переглянути файл
@@ -237,6 +237,15 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P
Ok(SettingsImport { settings, storage })
}
/// Import Settings… with a JetBrains `settings.zip`: reads it; the frontend maps and applies it.
#[tauri::command]
#[specta::specta]
pub async fn settings_read_jetbrains(path: PathBuf) -> IdeResult<ide_workspace::settings::JetBrainsSettings> {
tauri::async_runtime::spawn_blocking(move || ide_workspace::settings::read_jetbrains_settings(&path))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Effective source / test / resource / excluded roots of the window's project:
/// auto-detected from build files, overridden by Settings › Project Structure.
#[tauri::command]
+1
Переглянути файл
@@ -98,6 +98,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
app::settings_set,
app::settings_export,
app::settings_import,
app::settings_read_jetbrains,
app::project_rename,
app::project_roots,
app::cancel_request,
+12
Переглянути файл
@@ -81,6 +81,8 @@ export const commands = {
settingsExport: (path: string, storage: { [key in string]: string }) => typedError<null, IdeError>(__TAURI_INVOKE("settings_export", { path, storage })),
/** Import Settings…: replaces the global settings with the file's; every window re-reads them. */
settingsImport: (path: string) => typedError<SettingsImport_Serialize, IdeError>(__TAURI_INVOKE("settings_import", { path })),
/** Import Settings… with a JetBrains `settings.zip`: reads it; the frontend maps and applies it. */
settingsReadJetbrains: (path: string) => typedError<JetBrainsSettings, IdeError>(__TAURI_INVOKE("settings_read_jetbrains", { path })),
/**
* File › Rename Project…: the project's display name (title bar, recent
* projects); an empty `name` restores the folder name. Stored in the project's settings.
@@ -4919,6 +4921,16 @@ export type JdkVendor = {
name: string,
};
/**
* A JetBrains IDE's File › Manage IDE Settings › Export Settings… archive: every
* entry name, and the text of the entries OpenIDEA maps (the frontend does the mapping).
*/
export type JetBrainsSettings = {
entries: string[],
/** Entry name → text, for `colors/*.icls` and the `options/*.xml` files OpenIDEA reads. */
files: { [key in string]: string },
};
export type JvmFilePresentation = {
path: string,
presentation: JvmPresentation,
+130
Переглянути файл
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { planJetBrainsImport } from "../jetbrainsSettings";
const icls = (name: string) =>
`<scheme name="${name}" version="142" parent_scheme="Darcula"><attributes>` +
`<option name="DEFAULT_KEYWORD"><value><option name="FOREGROUND" value="cc856b" /></value></option>` +
`</attributes></scheme>`;
const component = (name: string, options: Record<string, string>) =>
`<application><component name="${name}">${Object.entries(options)
.map(([k, v]) => `<option name="${k}" value="${v}" />`)
.join("")}</component></application>`;
const editor = { fontSize: 13, customSchemes: {}, colorOverrides: {} };
/** A WebStorm export, trimmed to what the import reads. */
const webstorm = {
entries: [
"IntelliJ IDEA Global Settings",
"colors/_@user_Darcula.icls",
"options/editor-font.xml",
"options/colors.scheme.xml",
"options/editor.xml",
"options/ide.general.xml",
"options/vcs.xml",
"keymaps/macOS.xml",
"codestyles/Default.xml",
"inspection/Default.xml",
"installed.txt",
],
files: {
"colors/_@user_Darcula.icls": icls("_@user_Darcula"),
"options/colors.scheme.xml":
'<application><component name="EditorColorsManagerImpl"><global_color_scheme name="_@user_Darcula" /></component></application>',
"options/editor-font.xml": component("DefaultFont", {
FONT_SIZE: "17",
FONT_FAMILY: "JetBrains Maple Mono",
USE_LIGATURES: "true",
}),
"options/editor.xml": component("EditorSettings", {
USE_SOFT_WRAPS: "CONSOLE",
STRIP_TRAILING_SPACES: "Whole",
IS_ENSURE_NEWLINE_AT_EOF: "true",
SHOW_STICKY_LINES: "false",
}),
"options/ide.general.xml": component("GeneralSettings", {
showTipsOnStartup: "false",
confirmExit: "false",
}),
},
};
describe("planJetBrainsImport", () => {
it("maps a WebStorm export to the settings it has, and lists what is left out", () => {
const plan = planJetBrainsImport(webstorm, editor);
const writes = Object.fromEntries(plan.writes);
const schemes = writes["editor.customSchemes"] as Record<string, { name: string; parent: string }>;
const [id, scheme] = Object.entries(schemes)[0]!;
expect(scheme).toMatchObject({ name: "Darcula", parent: "dark" });
expect(writes).toEqual({
"editor.customSchemes": schemes,
"editor.colorScheme": id,
"editor.fontFamily": "JetBrains Maple Mono",
"editor.fontSize": 17,
"editor.ligatures": true,
"files.trimTrailingWhitespace": true,
"editor.general.stripTrailingScope": "all",
"files.ensureFinalNewline": true,
"editor.stickyScroll": false,
"editor.softWrap": false,
"general.confirmExit": false,
});
expect(plan.rows).toContainEqual({
label: "Color Scheme",
value: "Darcula",
});
expect(plan.rows).toContainEqual({ label: "Size", value: "17" });
expect(plan.rows).toContainEqual({
label: "Remove trailing spaces on:",
value: "All lines",
});
expect(plan.skipped).toEqual(["Keymap", "Code Style", "Inspections", "Plugins"]);
expect(plan.invalid).toEqual([]);
});
it("writes only the keys present, and derives the line height from IDEA's spacing", () => {
const plan = planJetBrainsImport(
{
entries: [],
files: {
"options/editor-font.xml": component("DefaultFont", {
LINE_SPACING: "1.5",
}),
"options/editor.xml": component("EditorSettings", {
USE_SOFT_WRAPS: "MAIN_EDITOR,CONSOLE",
STRIP_TRAILING_SPACES: "None",
}),
},
},
editor,
);
expect(plan.writes).toEqual([
["editor.lineHeight", 20],
["files.trimTrailingWhitespace", false],
["editor.softWrap", true],
]);
expect(plan.skipped).toEqual([]);
});
it("re-importing replaces the earlier copy of a scheme and drops its edits", () => {
const first = Object.fromEntries(planJetBrainsImport(webstorm, editor).writes);
const customs = first["editor.customSchemes"] as typeof editor.customSchemes;
const [id] = Object.keys(customs);
const again = planJetBrainsImport(webstorm, {
...editor,
customSchemes: customs,
colorOverrides: { [id!]: { keyword: { bold: true } }, other: {} },
});
const writes = Object.fromEntries(again.writes);
expect(Object.keys(writes["editor.customSchemes"] as object)).toEqual([id]);
expect(writes["editor.colorOverrides"]).toEqual({ other: {} });
expect(writes["editor.colorScheme"]).toBe(id);
});
it("skips colour schemes that cannot be read", () => {
const plan = planJetBrainsImport({ entries: [], files: { "colors/broken.icls": "<nope" } }, editor);
expect(plan.invalid).toEqual(["broken.icls"]);
expect(plan.writes).toEqual([]);
});
});
+21
Переглянути файл
@@ -214,3 +214,24 @@
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
/* Import Settings… › JetBrains settings.zip confirmation */
.importSummary {
display: flex;
flex-direction: column;
gap: 8px;
}
.importList {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 2px 16px;
}
.importList dt {
color: var(--fg-muted);
}
.importList dd {
margin: 0;
color: var(--fg-strong);
overflow-wrap: anywhere;
}
+198
Переглянути файл
@@ -0,0 +1,198 @@
/**
* Import Settings… with a JetBrains `settings.zip`: maps the archive's colour
* schemes and the editor / general options OpenIDEA has to settings writes, after
* a confirmation listing them. Only keys present in the archive are written
* (JetBrains leaves defaults out). Keymaps, code styles and the rest are listed
* as not imported.
*/
import { Fragment } from "react";
import type { JetBrainsSettings } from "@/bindings";
import { customSchemes } from "@/platform/colorScheme";
import { tx } from "@/platform/i18n";
import { importScheme, parseIcls } from "@/platform/icls";
import { call, commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { ask } from "@/workbench/messages";
import styles from "./app.module.css";
export interface JetBrainsImportPlan {
/** Settings writes, in order: `settingsSet("global", key, JSON.stringify(value))`. */
writes: [key: string, value: unknown][];
/** What the confirmation lists (translated): the setting and its imported value. */
rows: { label: string; value: string }[];
/** Categories present in the archive that are not imported (translated). */
skipped: string[];
/** `.icls` files that could not be read. */
invalid: string[];
}
interface EditorState {
fontSize: number | null;
customSchemes: unknown;
colorOverrides: Record<string, unknown>;
}
const USER_PREFIX = "_@user_";
/**
* `<option name value>` of `<component name>`; empty when the file or component is
* absent. With `tag`, the `name` of that child element instead, under key "".
*/
function options(xml: string | undefined, component: string, tag?: string): Map<string, string> {
const out = new Map<string, string>();
if (!xml) return out;
const doc = new DOMParser().parseFromString(xml, "application/xml");
if (doc.querySelector("parsererror")) return out;
for (const c of Array.from(doc.documentElement.children))
if (c.tagName === "component" && c.getAttribute("name") === component)
for (const o of Array.from(c.children)) {
const name = o.getAttribute("name");
const value = o.getAttribute("value");
if (tag && o.tagName === tag && name !== null) out.set("", name);
else if (!tag && o.tagName === "option" && name && value !== null) out.set(name, value);
}
return out;
}
const bool = (v: string | undefined) => (v === undefined ? undefined : v === "true");
const num = (v: string | undefined) => {
const n = v === undefined ? NaN : Number(v);
return Number.isFinite(n) && n > 0 ? n : undefined;
};
const onOff = (v: boolean) => (v ? tx("On") : tx("Off"));
export function planJetBrainsImport(bundle: JetBrainsSettings, editor: EditorState): JetBrainsImportPlan {
const plan: JetBrainsImportPlan = { writes: [], rows: [], skipped: [], invalid: [] };
const add = (key: string, value: string | number | boolean | undefined, label: string, shown?: string) => {
if (value === undefined) return;
plan.writes.push([key, value]);
plan.rows.push({ label, value: shown ?? (typeof value === "boolean" ? onOff(value) : String(value)) });
};
let customs = customSchemes(editor.customSchemes);
let overrides = editor.colorOverrides;
const imported = new Map<string, string>(); // name in the .icls → scheme id
for (const [file, text] of Object.entries(bundle.files)) {
if (!file.startsWith("colors/")) continue;
let parsed;
try {
parsed = parseIcls(text);
} catch {
plan.invalid.push(file.slice("colors/".length));
continue;
}
const original = parsed.scheme.name;
const name = original.startsWith(USER_PREFIX) ? original.slice(USER_PREFIX.length) : original;
// Overwrites an earlier import of the same scheme instead of adding "Name (2)".
const next = importScheme(customs, { ...parsed.scheme, name }, true);
customs = next.customs;
if (overrides[next.id]) overrides = Object.fromEntries(Object.entries(overrides).filter(([id]) => id !== next.id));
imported.set(original, next.id);
plan.rows.push({ label: tx("Color Scheme"), value: customs[next.id]?.name ?? name });
}
if (imported.size) {
plan.writes.push(["editor.customSchemes", customs]);
if (overrides !== editor.colorOverrides) plan.writes.push(["editor.colorOverrides", overrides]);
const global = options(bundle.files["options/colors.scheme.xml"], "EditorColorsManagerImpl", "global_color_scheme");
const id = imported.get(global.get("") ?? "");
if (id) add("editor.colorScheme", id, tx("Current color scheme"), customs[id]?.name);
}
const font = options(bundle.files["options/editor-font.xml"], "DefaultFont");
add("editor.fontFamily", font.get("FONT_FAMILY")?.trim() || undefined, tx("Font"));
const size = num(font.get("FONT_SIZE"));
add("editor.fontSize", size, tx("Size"));
const spacing = num(font.get("LINE_SPACING"));
const base = size ?? editor.fontSize;
add("editor.lineHeight", spacing && base ? Math.round(base * spacing) : undefined, tx("Line height"));
add("editor.ligatures", bool(font.get("USE_LIGATURES")), tx("Enable ligatures"));
const ed = options(bundle.files["options/editor.xml"], "EditorSettings");
const strip = ed.get("STRIP_TRAILING_SPACES"); // None | Changed | Whole
if (strip === "Changed" || strip === "Whole") {
const scope = strip === "Whole" ? "all" : "modified";
plan.writes.push(["files.trimTrailingWhitespace", true]);
add(
"editor.general.stripTrailingScope",
scope,
tx("Remove trailing spaces on:"),
tx(strip === "Whole" ? "All lines" : "Modified lines"),
);
} else if (strip === "None") add("files.trimTrailingWhitespace", false, tx("Remove trailing spaces on:"));
add(
"files.ensureFinalNewline",
bool(ed.get("IS_ENSURE_NEWLINE_AT_EOF")),
tx("Ensure every saved file ends with a line break"),
);
add("editor.stickyScroll", bool(ed.get("SHOW_STICKY_LINES")), tx("Show sticky lines while scrolling"));
add("editor.showWhitespace", bool(ed.get("IS_WHITESPACES_SHOWN")), tx("Show whitespaces"));
// A comma-separated list of places: MAIN_EDITOR, CONSOLE, PREVIEW.
const wraps = ed.get("USE_SOFT_WRAPS");
add(
"editor.softWrap",
wraps === undefined ? undefined : wraps.split(",").includes("MAIN_EDITOR"),
tx("Soft-wrap files"),
);
const general = options(bundle.files["options/ide.general.xml"], "GeneralSettings");
add("general.confirmExit", bool(general.get("confirmExit")), tx("Confirm before exiting the IDE"));
const has = (prefix: string) => bundle.entries.some((e) => e.startsWith(prefix));
if (has("keymaps/")) plan.skipped.push(tx("Keymap"));
if (has("codestyles/")) plan.skipped.push(tx("Code Style"));
if (has("templates/")) plan.skipped.push(tx("Live templates"));
if (has("inspection/")) plan.skipped.push(tx("Inspections"));
if (has("installed.txt")) plan.skipped.push(tx("Plugins"));
return plan;
}
function Summary({ plan }: { plan: JetBrainsImportPlan }) {
const left = [...plan.skipped, ...plan.invalid];
return (
<div className={styles.importSummary}>
<div>{tx("These settings will be imported:")}</div>
<dl className={styles.importList}>
{plan.rows.map((row, i) => (
<Fragment key={i}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</Fragment>
))}
</dl>
{left.length > 0 && (
<div>
{tx("Not imported:")} {left.join(", ")}
</div>
)}
</div>
);
}
/** Reads `path`, asks, then writes the mapped settings to the global layer. */
export async function importJetBrainsSettings(path: string) {
const bundle = await call(commands.settingsReadJetbrains(path));
const { editor } = await commands.settingsGetGlobal();
const plan = planJetBrainsImport(bundle, editor);
if (!plan.writes.length) {
notifications().notify({
level: "warning",
title: "No settings OpenIDEA can import were found in this archive",
key: "app.settingsImport",
});
return;
}
const { button } = await ask({
title: tx("Import JetBrains Settings"),
body: <Summary plan={plan} />,
buttons: [{ id: "import", label: tx("Import"), primary: true }],
width: 520,
});
if (button !== "import") return;
let settings = useProject.getState().settings;
for (const [key, value] of plan.writes)
settings = await call(commands.settingsSet("global", key, JSON.stringify(value)));
useProject.setState({ settings });
notifications().notify({ level: "info", title: "Settings imported", body: path, key: "app.settingsImport" });
}
+3 -1
Переглянути файл
@@ -9,6 +9,7 @@ import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications, registerEditorBanner } from "@/workbench/contributions";
import { promptInput } from "@/workbench/inputDialogs";
import { importJetBrainsSettings } from "./jetbrainsSettings";
import { showNewProject } from "./NewProject";
import { tx } from "@/platform/i18n";
@@ -80,11 +81,12 @@ async function importSettings() {
const picked = await open({
title: "Import Settings",
multiple: false,
filters: [{ name: "OpenIDEA settings", extensions: ["json"] }],
filters: [{ name: "OpenIDEA or JetBrains settings", extensions: ["json", "zip"] }],
});
const path = Array.isArray(picked) ? picked[0] : picked;
if (!path) return;
try {
if (/\.zip$/i.test(path)) return await importJetBrainsSettings(path);
const result = await call(commands.settingsImport(path));
for (const [key, value] of Object.entries(result.storage))
if (isExported(key))
+6
Переглянути файл
@@ -1,4 +1,10 @@
{
"OpenIDEA or JetBrains settings": "OpenIDEA or JetBrains settings",
"Import JetBrains Settings": "Import JetBrains Settings",
"These settings will be imported:": "These settings will be imported:",
"Not imported:": "Not imported:",
"Current color scheme": "Current color scheme",
"No settings OpenIDEA can import were found in this archive": "No settings OpenIDEA can import were found in this archive",
"Overwrite existing branch": "Overwrite existing branch",
"Branch '{n}' already exists": "Branch '{n}' already exists",
"Cannot copy reference": "Cannot copy reference",
+6
Переглянути файл
@@ -3016,6 +3016,12 @@
"Settings imported": "设置已导入",
"Restart to apply imported templates, macros, layouts and view options.": "重启以应用导入的模板、宏、布局和视图选项。",
"Cannot import settings": "无法导入设置",
"OpenIDEA or JetBrains settings": "OpenIDEA 或 JetBrains 设置",
"Import JetBrains Settings": "导入 JetBrains 设置",
"These settings will be imported:": "将导入以下设置:",
"Not imported:": "不导入:",
"Current color scheme": "当前配色方案",
"No settings OpenIDEA can import were found in this archive": "此压缩包中没有 OpenIDEA 可以导入的设置",
"Rename Project": "重命名项目",
"Rename Project…": "重命名项目…",
"New project name:": "新项目名称:",