From 1ab4edc0d77328c779d82fc733cd7a41c70887e3 Mon Sep 17 00:00:00 2001 From: LU XIANGRUI <1078666705@qq.com> Date: Sat, 7 Nov 2026 22:13:48 +0900 Subject: [PATCH] feat(settings): import JetBrains settings.zip (colour schemes, editor font and options) (#1709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ide-workspace/Cargo.toml | 2 +- crates/ide-workspace/src/settings.rs | 74 +++++++ src-tauri/src/commands/app.rs | 9 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 12 ++ .../app/__tests__/jetbrainsSettings.test.ts | 130 ++++++++++++ src/features/app/app.module.css | 21 ++ src/features/app/jetbrainsSettings.tsx | 198 ++++++++++++++++++ src/features/app/projectActions.ts | 4 +- src/platform/locales/text/en.json | 6 + src/platform/locales/text/zh.json | 6 + 11 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 src/features/app/__tests__/jetbrainsSettings.test.ts create mode 100644 src/features/app/jetbrainsSettings.tsx diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index 5ebf071f4..92e264dc4 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -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 diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 8327f134f..bc3b72603 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -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, + /// Entry name → text, for `colors/*.icls` and the `options/*.xml` files OpenIDEA reads. + pub files: BTreeMap, +} + +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 { + 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", ""), + ("options/editor-font.xml", ""), + ("options/vcs.xml", ""), + ("keymaps/macOS.xml", ""), + ], + ); + let read = read_jetbrains_settings(&ok).unwrap(); + assert_eq!(read.entries.len(), 5); + assert_eq!(read.files.keys().collect::>(), ["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(); diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index a817273da..3774611e4 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -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 { + 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] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d42a117fa..ecb5fd3a8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -98,6 +98,7 @@ pub fn builder() -> tauri_specta::Builder { app::settings_set, app::settings_export, app::settings_import, + app::settings_read_jetbrains, app::project_rename, app::project_roots, app::cancel_request, diff --git a/src/bindings.ts b/src/bindings.ts index 06f7b2cfe..91103e03b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -81,6 +81,8 @@ export const commands = { settingsExport: (path: string, storage: { [key in string]: string }) => typedError(__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(__TAURI_INVOKE("settings_import", { path })), + /** Import Settings… with a JetBrains `settings.zip`: reads it; the frontend maps and applies it. */ + settingsReadJetbrains: (path: string) => typedError(__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, diff --git a/src/features/app/__tests__/jetbrainsSettings.test.ts b/src/features/app/__tests__/jetbrainsSettings.test.ts new file mode 100644 index 000000000..cee640330 --- /dev/null +++ b/src/features/app/__tests__/jetbrainsSettings.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { planJetBrainsImport } from "../jetbrainsSettings"; + +const icls = (name: string) => + `` + + `` + + ``; + +const component = (name: string, options: Record) => + `${Object.entries(options) + .map(([k, v]) => ``; + +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": + '', + "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; + 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": "; +} + +const USER_PREFIX = "_@user_"; + +/** + * `