дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Project & files - Project view modes (Project Files / Open Files / All Changed Files / Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go, Maven/Gradle jars; read-only jar/zip browsing - ⌘N New popup with type search and more file kinds; New Project, Rename Project, Reload All from Disk, Export/Import Settings, Power Save Mode - File type associations, extra content roots and exclude patterns (indexed and watched without reopening the project) Editor & navigation - Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline, history; Find in Files: Module scope, custom pattern scopes, multiline - Search Everywhere filters, Assign Shortcut, settings results; named bookmark lists, file/folder bookmarks; Structure filters and Show Inherited; Recent Locations timestamps and breadcrumbs; Code Vision author - Completion options, editor tab placement/layout, inline completion (LSP 3.18) - Spell checking (Typo inspection, ide-spell) and Inspections settings with custom profiles; Code Style schemes, formatter markers, Do not format Run, VCS, services, database - Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker, Compound), Modify options, Before launch, env dialog, Current File, Emulate terminal (PTY) - Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees - Generic Services host (Docker connections/registries, run sessions); TODO scope/changes tabs with changelists; Coverage report import - Data sources: wire-compatible vendors, URL import, SSL certs, Options, Schemas and Advanced tabs, color and comment Workbench - Run Anything, Customize Main Toolbar, named tool window layouts, menu completeness, Code Cleanup, macros, "Don't Show Again", editor banners - Chinese translations for the new UI Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
435 рядки
18 KiB
Rust
435 рядки
18 KiB
Rust
use ide_core::settings::Settings;
|
||
use ide_core::{IdeError, IdeResult, project_dir};
|
||
use parking_lot::RwLock;
|
||
use serde_json::Value as Json;
|
||
use std::collections::BTreeMap;
|
||
use std::path::{Path, PathBuf};
|
||
use toml_edit::{DocumentMut, Item, Table, TableLike};
|
||
|
||
/// Layered settings: defaults → `<app-config>/settings.toml` → `<project>/.openidea/settings.toml`.
|
||
/// Writes go through `toml_edit` so user comments survive.
|
||
///
|
||
/// TOML keys use the camelCase names of the serde [`Settings`] struct, e.g.
|
||
/// `[editor]` / `fontSize = 14`. A layer that fails to parse or holds values of
|
||
/// the wrong type is logged and ignored as a whole.
|
||
const PROJECT_FILE: &str = "settings.toml";
|
||
|
||
pub struct SettingsStore {
|
||
global_file: PathBuf,
|
||
project_file: Option<PathBuf>,
|
||
effective: RwLock<Settings>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum SettingsScope {
|
||
Global,
|
||
Project,
|
||
}
|
||
|
||
fn defaults_json() -> Json {
|
||
serde_json::to_value(Settings::default()).expect("settings serialize")
|
||
}
|
||
|
||
fn deep_merge(base: &mut Json, layer: Json) {
|
||
match (base, layer) {
|
||
(Json::Object(base), Json::Object(layer)) => {
|
||
for (k, v) in layer {
|
||
match base.get_mut(&k) {
|
||
Some(existing) => deep_merge(existing, v),
|
||
None => {
|
||
base.insert(k, v);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
(base, layer) => *base = layer,
|
||
}
|
||
}
|
||
|
||
fn parse_layer(text: &str) -> Result<Json, String> {
|
||
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||
serde_json::to_value(table).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// Reads one layer; `None` when missing or invalid (logged).
|
||
fn read_layer(file: &Path) -> Option<Json> {
|
||
let text = match std::fs::read_to_string(file) {
|
||
Ok(t) => t,
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||
Err(e) => {
|
||
tracing::warn!("cannot read {}: {e}", file.display());
|
||
return None;
|
||
}
|
||
};
|
||
match parse_layer(&text) {
|
||
Ok(v) => Some(v),
|
||
Err(e) => {
|
||
tracing::warn!("{} is not valid TOML, ignoring it: {e}", file.display());
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Merges `layer` onto `merged` if the result still deserializes.
|
||
fn try_merge(merged: &mut Json, layer: Json) -> Result<(), String> {
|
||
let mut candidate = merged.clone();
|
||
deep_merge(&mut candidate, layer);
|
||
serde_json::from_value::<Settings>(candidate.clone()).map_err(|e| e.to_string())?;
|
||
*merged = candidate;
|
||
Ok(())
|
||
}
|
||
|
||
fn json_to_toml(value: &Json) -> IdeResult<toml_edit::Value> {
|
||
Ok(match value {
|
||
Json::Null => return Err(IdeError::invalid("null is not allowed inside a settings value")),
|
||
Json::Bool(b) => (*b).into(),
|
||
Json::Number(n) => match n.as_i64() {
|
||
Some(i) => i.into(),
|
||
None => n.as_f64().ok_or_else(|| IdeError::invalid(format!("unsupported number {n}")))?.into(),
|
||
},
|
||
Json::String(s) => s.as_str().into(),
|
||
Json::Array(items) => {
|
||
let mut arr = toml_edit::Array::new();
|
||
for item in items {
|
||
arr.push(json_to_toml(item)?);
|
||
}
|
||
arr.into()
|
||
}
|
||
Json::Object(map) => {
|
||
let mut t = toml_edit::InlineTable::new();
|
||
for (k, v) in map {
|
||
t.insert(k, json_to_toml(v)?);
|
||
}
|
||
t.into()
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Sets (or removes, for `None`) a dotted key in a TOML document.
|
||
fn set_in_document(doc: &mut DocumentMut, segments: &[&str], value: Option<toml_edit::Value>) {
|
||
let (last, parents) = segments.split_last().expect("non-empty key");
|
||
let mut table: &mut dyn TableLike = doc.as_table_mut();
|
||
for seg in parents {
|
||
if !table.get(seg).is_some_and(Item::is_table_like) {
|
||
if value.is_none() {
|
||
return;
|
||
}
|
||
table.insert(seg, Item::Table(Table::new()));
|
||
}
|
||
table = table.get_mut(seg).and_then(Item::as_table_like_mut).expect("table just ensured");
|
||
}
|
||
match value {
|
||
Some(v) => match table.get_mut(last) {
|
||
// Keep the key's decor (comments, spacing) when replacing a value.
|
||
Some(Item::Value(existing)) => {
|
||
let decor = existing.decor().clone();
|
||
*existing = v;
|
||
*existing.decor_mut() = decor;
|
||
}
|
||
_ => {
|
||
table.insert(last, Item::Value(v));
|
||
}
|
||
},
|
||
None => {
|
||
table.remove(last);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl SettingsStore {
|
||
pub fn load(config_dir: &Path, project_root: Option<&Path>) -> Self {
|
||
let store = Self {
|
||
global_file: config_dir.join("settings.toml"),
|
||
project_file: project_root.map(|r| project_dir::path(r, PROJECT_FILE)),
|
||
effective: RwLock::new(Settings::default()),
|
||
};
|
||
store.reload();
|
||
store
|
||
}
|
||
|
||
pub fn file(&self, scope: SettingsScope) -> Option<&Path> {
|
||
match scope {
|
||
SettingsScope::Global => Some(&self.global_file),
|
||
SettingsScope::Project => self.project_file.as_deref(),
|
||
}
|
||
}
|
||
|
||
fn layers(&self) -> Vec<(SettingsScope, PathBuf)> {
|
||
[SettingsScope::Global, SettingsScope::Project]
|
||
.into_iter()
|
||
.filter_map(|scope| Some((scope, self.file(scope)?.to_path_buf())))
|
||
.collect()
|
||
}
|
||
|
||
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)
|
||
{
|
||
tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display());
|
||
}
|
||
}
|
||
serde_json::from_value(merged).unwrap_or_default()
|
||
}
|
||
|
||
/// Effective (merged) settings.
|
||
pub fn effective(&self) -> Settings {
|
||
self.effective.read().clone()
|
||
}
|
||
|
||
/// Sets one dotted key, e.g. `editor.fontSize`, to a JSON value in `scope`.
|
||
/// `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> {
|
||
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()) {
|
||
return Err(IdeError::invalid(format!("invalid settings key {key:?}")));
|
||
}
|
||
let mut cursor = &defaults_json();
|
||
for seg in &segments {
|
||
cursor = cursor.get(seg).ok_or_else(|| IdeError::invalid(format!("unknown setting {key:?}")))?;
|
||
}
|
||
|
||
let text = match std::fs::read_to_string(&file) {
|
||
Ok(t) => t,
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||
Err(e) => return Err(e.into()),
|
||
};
|
||
let mut doc: DocumentMut = text
|
||
.parse()
|
||
.map_err(|e| IdeError::invalid(format!("{} is not valid TOML; fix it first: {e}", file.display())))?;
|
||
let new_value = if value.is_null() { None } else { Some(json_to_toml(&value)?) };
|
||
set_in_document(&mut doc, &segments, new_value);
|
||
let new_text = doc.to_string();
|
||
|
||
// Validate the resulting stack before touching the disk.
|
||
let mut merged = defaults_json();
|
||
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 };
|
||
if let Err(e) = try_merge(&mut merged, layer)
|
||
&& is_target
|
||
{
|
||
return Err(IdeError::invalid(format!("invalid value for {key}: {e}")));
|
||
}
|
||
}
|
||
|
||
if let Some(dir) = file.parent() {
|
||
std::fs::create_dir_all(dir)?;
|
||
}
|
||
ide_vfs::write_atomic(&file, new_text.as_bytes())?;
|
||
Ok(self.reload())
|
||
}
|
||
|
||
/// Re-reads all layers from disk.
|
||
pub fn reload(&self) -> Settings {
|
||
let settings = self.compute();
|
||
*self.effective.write() = settings.clone();
|
||
settings
|
||
}
|
||
|
||
/// File › Manage IDE Settings › Export Settings…: the global layer plus
|
||
/// frontend-held IDE state (`storage`: keymaps' companions such as live
|
||
/// templates, macros) as one JSON file.
|
||
pub fn export_bundle(&self, path: &Path, storage: BTreeMap<String, String>) -> IdeResult<()> {
|
||
let settings = match std::fs::read_to_string(&self.global_file) {
|
||
Ok(t) => t,
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||
Err(e) => return Err(e.into()),
|
||
};
|
||
let bundle = SettingsBundle { format: BUNDLE_FORMAT.into(), version: BUNDLE_VERSION, settings, storage };
|
||
let json = serde_json::to_vec_pretty(&bundle).map_err(|e| IdeError::internal(e.to_string()))?;
|
||
ide_vfs::write_atomic(path, &json)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Import Settings…: replaces the global layer with the bundle's (validated
|
||
/// first) and returns the new effective settings and the bundle's `storage`.
|
||
pub fn import_bundle(&self, path: &Path) -> IdeResult<(Settings, BTreeMap<String, String>)> {
|
||
let bytes = std::fs::read(path)?;
|
||
let bundle: SettingsBundle = serde_json::from_slice(&bytes)
|
||
.map_err(|e| IdeError::invalid(format!("not an OpenIDEA settings file: {e}")))?;
|
||
if bundle.format != BUNDLE_FORMAT || bundle.version > BUNDLE_VERSION {
|
||
return Err(IdeError::invalid("not an OpenIDEA settings file, or one from a newer version"));
|
||
}
|
||
let layer = parse_layer(&bundle.settings).map_err(|e| IdeError::invalid(format!("invalid settings: {e}")))?;
|
||
try_merge(&mut defaults_json(), layer).map_err(|e| IdeError::invalid(format!("invalid settings: {e}")))?;
|
||
if let Some(dir) = self.global_file.parent() {
|
||
std::fs::create_dir_all(dir)?;
|
||
}
|
||
ide_vfs::write_atomic(&self.global_file, bundle.settings.as_bytes())?;
|
||
Ok((self.reload(), bundle.storage))
|
||
}
|
||
}
|
||
|
||
const BUNDLE_FORMAT: &str = "openidea-settings";
|
||
const BUNDLE_VERSION: u32 = 1;
|
||
|
||
#[derive(serde::Serialize, serde::Deserialize)]
|
||
struct SettingsBundle {
|
||
format: String,
|
||
version: u32,
|
||
/// The global `settings.toml`, verbatim (comments included).
|
||
settings: String,
|
||
#[serde(default)]
|
||
storage: BTreeMap<String, String>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn layering_and_fallback() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let cfg = tmp.path().join("cfg");
|
||
let proj = tmp.path().join("proj");
|
||
std::fs::create_dir_all(&cfg).unwrap();
|
||
std::fs::create_dir_all(proj.join(".openidea")).unwrap();
|
||
std::fs::write(cfg.join("settings.toml"), "keymap = \"vscode\"\n[editor]\nfontSize = 15\ntabSize = 2\n")
|
||
.unwrap();
|
||
std::fs::write(proj.join(".openidea/settings.toml"), "[editor]\ntabSize = 8\n").unwrap();
|
||
|
||
let s = SettingsStore::load(&cfg, Some(&proj)).effective();
|
||
assert_eq!(s.keymap, "vscode");
|
||
assert_eq!(s.editor.font_size, 15.0);
|
||
assert_eq!(s.editor.tab_size, 8);
|
||
assert_eq!(s.editor.font_family, "JetBrains Mono");
|
||
|
||
// Invalid project layer: ignored, global still applies.
|
||
std::fs::write(proj.join(".openidea/settings.toml"), "[editor]\ntabSize = \"wide\"\n").unwrap();
|
||
let store = SettingsStore::load(&cfg, Some(&proj));
|
||
assert_eq!(store.effective().editor.tab_size, 2);
|
||
// Broken TOML in the global layer: defaults.
|
||
std::fs::write(cfg.join("settings.toml"), "[editor\n").unwrap();
|
||
assert_eq!(store.reload().editor.font_size, 13.0);
|
||
|
||
let global_only = SettingsStore::load(&cfg, None);
|
||
assert_eq!(
|
||
global_only.set(SettingsScope::Project, "editor.fontSize", json!(1)).unwrap_err().kind,
|
||
ide_core::error::ErrorKind::InvalidInput
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn set_preserves_comments_and_validates() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let cfg = tmp.path().join("cfg");
|
||
let proj = tmp.path().join("proj");
|
||
std::fs::create_dir_all(&cfg).unwrap();
|
||
let original = "# My settings\n\n[editor]\n# bigger is better\nfontSize = 14 # pt\nsoftWrap = true\n";
|
||
std::fs::write(cfg.join("settings.toml"), original).unwrap();
|
||
let store = SettingsStore::load(&cfg, Some(&proj));
|
||
|
||
let s = store.set(SettingsScope::Global, "editor.fontSize", json!(16)).unwrap();
|
||
assert_eq!(s.editor.font_size, 16.0);
|
||
assert!(s.editor.soft_wrap);
|
||
let text = std::fs::read_to_string(cfg.join("settings.toml")).unwrap();
|
||
assert_eq!(text, "# My settings\n\n[editor]\n# bigger is better\nfontSize = 16 # pt\nsoftWrap = true\n");
|
||
|
||
let s = store.set(SettingsScope::Global, "appearance.theme", json!("light")).unwrap();
|
||
assert_eq!(s.appearance.theme, "light");
|
||
let s = store.set(SettingsScope::Global, "files.exclude", json!(["**/dist"])).unwrap();
|
||
assert_eq!(s.files.exclude, vec!["**/dist".to_string()]);
|
||
let s = store.set(SettingsScope::Global, "editor.lineHeight", json!(22.5)).unwrap();
|
||
assert_eq!(s.editor.line_height, 22.5);
|
||
// Map-valued setting whose keys contain dots, replaced as a whole.
|
||
let s = store
|
||
.set(
|
||
SettingsScope::Global,
|
||
"appearance.statusBarWidgets",
|
||
json!({"perf.memory": true, "editor.caret": false}),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(s.appearance.status_bar_widgets.get("perf.memory"), Some(&true));
|
||
let s =
|
||
store.set(SettingsScope::Global, "appearance.statusBarWidgets", json!({"editor.caret": false})).unwrap();
|
||
assert_eq!(s.appearance.status_bar_widgets.len(), 1);
|
||
|
||
// Project layer is created on demand and wins.
|
||
let s = store.set(SettingsScope::Project, "editor.fontSize", json!(12)).unwrap();
|
||
assert_eq!(s.editor.font_size, 12.0);
|
||
assert_eq!(std::fs::read_to_string(proj.join(".openidea/settings.toml")).unwrap(), "[editor]\nfontSize = 12\n");
|
||
let s = store.set(SettingsScope::Project, "editor.fontSize", json!(null)).unwrap();
|
||
assert_eq!(s.editor.font_size, 16.0);
|
||
|
||
let err = |key: &str, v: Json| store.set(SettingsScope::Global, key, v).unwrap_err().kind;
|
||
assert_eq!(err("editor.fontSize", json!("huge")), ide_core::error::ErrorKind::InvalidInput);
|
||
assert_eq!(err("editor.nope", json!(1)), ide_core::error::ErrorKind::InvalidInput);
|
||
assert_eq!(err("editor..x", json!(1)), ide_core::error::ErrorKind::InvalidInput);
|
||
assert_eq!(store.effective().editor.font_size, 16.0);
|
||
|
||
let reloaded = SettingsStore::load(&cfg, Some(&proj)).effective();
|
||
assert_eq!(reloaded, store.effective());
|
||
}
|
||
|
||
#[test]
|
||
fn inspection_profiles_live_in_their_layers() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let cfg = tmp.path().join("cfg");
|
||
let proj = tmp.path().join("proj");
|
||
std::fs::create_dir_all(&cfg).unwrap();
|
||
let store = SettingsStore::load(&cfg, Some(&proj));
|
||
store.set(SettingsScope::Global, "inspections.default", json!({"lsp:ruff:F401": {"enabled": false}})).unwrap();
|
||
store.set(SettingsScope::Project, "inspections.project", json!({"lsp:ruff": {"severity": "typo"}})).unwrap();
|
||
let s = store.set(SettingsScope::Project, "inspections.profile", json!("project")).unwrap();
|
||
assert_eq!(s.inspections.profile, "project");
|
||
assert_eq!(s.inspections.default["lsp:ruff:F401"].enabled, Some(false));
|
||
assert_eq!(s.inspections.project["lsp:ruff"].severity, Some(ide_core::settings::InspectionLevel::Typo));
|
||
assert!(store.set(SettingsScope::Project, "inspections.project", json!({"x": {"severity": "loud"}})).is_err());
|
||
let global = std::fs::read_to_string(cfg.join("settings.toml")).unwrap();
|
||
assert!(!global.contains("profile"), "{global}");
|
||
}
|
||
|
||
#[test]
|
||
fn settings_bundle_round_trips_and_rejects_invalid_files() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let (a, b) = (tmp.path().join("a"), tmp.path().join("b"));
|
||
std::fs::create_dir_all(&a).unwrap();
|
||
let source = SettingsStore::load(&a, None);
|
||
source.set(SettingsScope::Global, "editor.fontSize", json!(17)).unwrap();
|
||
source.set(SettingsScope::Global, "general.powerSaveMode", json!(true)).unwrap();
|
||
let file = tmp.path().join("settings.json");
|
||
let storage = BTreeMap::from([("openidea.macros.v1".to_owned(), "[]".to_owned())]);
|
||
source.export_bundle(&file, storage.clone()).unwrap();
|
||
|
||
let target = SettingsStore::load(&b, None);
|
||
let (settings, imported) = target.import_bundle(&file).unwrap();
|
||
assert_eq!(settings.editor.font_size, 17.0);
|
||
assert!(settings.general.power_save_mode);
|
||
assert_eq!(imported, storage);
|
||
assert_eq!(target.effective(), source.effective());
|
||
|
||
let bad = tmp.path().join("bad.json");
|
||
std::fs::write(&bad, r#"{"format":"openidea-settings","version":1,"settings":"[editor]\nfontSize = \"x\"\n"}"#)
|
||
.unwrap();
|
||
assert!(target.import_bundle(&bad).is_err());
|
||
std::fs::write(&bad, r#"{"format":"other","version":1,"settings":""}"#).unwrap();
|
||
assert!(target.import_bundle(&bad).is_err());
|
||
assert_eq!(target.effective().editor.font_size, 17.0);
|
||
}
|
||
|
||
#[test]
|
||
fn main_toolbar_can_be_set_and_reset() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let store = SettingsStore::load(tmp.path(), None);
|
||
let s = store
|
||
.set(
|
||
SettingsScope::Global,
|
||
"appearance.mainToolbar",
|
||
json!({"left": ["project"], "right": [], "hidden": ["vcs.branch"]}),
|
||
)
|
||
.unwrap();
|
||
let toolbar = s.appearance.main_toolbar.unwrap();
|
||
assert_eq!(toolbar.left, ["project"]);
|
||
assert_eq!(toolbar.hidden, ["vcs.branch"]);
|
||
let s = store.set(SettingsScope::Global, "appearance.mainToolbar", Json::Null).unwrap();
|
||
assert_eq!(s.appearance.main_toolbar, None);
|
||
}
|
||
}
|