mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
Settings › Plugins (gear menu) gets Import Theme from IntelliJ IDE… (theme plugins of the JetBrains IDEs on this machine, the one each IDE uses first) and Import IntelliJ Theme from File… (a Marketplace .jar / .zip or a .theme.json). The host reads the plugin with size limits; the UI converts it with the IntelliJ mapping (moved from scripts/theme to src/features/plugins/intellijTheme.ts, still used by pnpm theme:import-intellij) into one OpenIDEA theme plugin (engines.openidea ^1.0) with all of its themes, installed through the usual install page, and applies the chosen theme. Theme pickers separate each plugin's themes, and Quick Switch Scheme › Theme previews the theme under the pointer or keyboard after 500 ms and restores the selected one when closed without a choice. Co-authored-by: ldg030201 <100822592+ldg030201@users.noreply.github.com>
253 lines
8.4 KiB
Rust
253 lines
8.4 KiB
Rust
//! The `themes` extension point: design-token overrides applied as CSS
|
|
//! variables on `:root`. Theme and editor-scheme files are JSON that may
|
|
//! contain `//` and `/* */` comments.
|
|
//!
|
|
//! ```toml
|
|
//! [[contributes.themes]]
|
|
//! path = "themes/harbor-night.json"
|
|
//! editor-scheme = "themes/harbor-night.scheme.json" # optional
|
|
//! ```
|
|
|
|
use ide_extension::{ExtensionPoint, Package, Placement, Prepared, UiContribution, UiStore, decode};
|
|
use serde::{Deserialize, Serialize};
|
|
use specta::Type;
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
pub const POINT_ID: &str = "themes";
|
|
|
|
const MAX_TOKENS: usize = 512;
|
|
const MAX_VALUE_LEN: usize = 256;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum ThemeKind {
|
|
Dark,
|
|
Light,
|
|
}
|
|
|
|
/// `{"id", "name", "kind", "tokens": {"--bg-app": "#002b36", ...}}`.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ThemeDef {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub kind: ThemeKind,
|
|
pub tokens: BTreeMap<String, String>,
|
|
}
|
|
|
|
/// `{"name"?, "tokens": {...}}`: an editor colour scheme merged over a theme.
|
|
#[derive(Deserialize)]
|
|
struct EditorScheme {
|
|
tokens: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl ThemeDef {
|
|
pub fn parse(src: &str) -> Result<Self, String> {
|
|
let theme: Self =
|
|
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid theme JSON: {e}"))?;
|
|
theme.validate()?;
|
|
Ok(theme)
|
|
}
|
|
|
|
/// Merges an editor colour scheme file over the theme's tokens (the scheme wins).
|
|
pub fn merge_editor_scheme(&mut self, src: &str) -> Result<(), String> {
|
|
let scheme: EditorScheme =
|
|
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid editor scheme JSON: {e}"))?;
|
|
self.tokens.extend(scheme.tokens);
|
|
self.validate()
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
ide_extension::validate_item_id(&self.id, "theme id")?;
|
|
if self.name.trim().is_empty() {
|
|
return Err("theme name must not be empty".into());
|
|
}
|
|
if self.tokens.len() > MAX_TOKENS {
|
|
return Err(format!("too many tokens (max {MAX_TOKENS})"));
|
|
}
|
|
for (name, value) in &self.tokens {
|
|
let valid_name = name.len() > 2
|
|
&& name.len() <= 64
|
|
&& name.starts_with("--")
|
|
&& name[2..].bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
|
if !valid_name {
|
|
return Err(format!("invalid token name {name:?}: expected a CSS variable like --bg-app"));
|
|
}
|
|
if !is_safe_value(value) {
|
|
return Err(format!("token {name}: unsupported value {value:?}"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct ThemesPoint {
|
|
store: Arc<UiStore>,
|
|
}
|
|
|
|
impl ThemesPoint {
|
|
pub fn new(store: Arc<UiStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
|
struct Decl {
|
|
path: PathBuf,
|
|
#[serde(default)]
|
|
editor_scheme: Option<PathBuf>,
|
|
}
|
|
|
|
impl ExtensionPoint for ThemesPoint {
|
|
fn id(&self) -> &'static str {
|
|
POINT_ID
|
|
}
|
|
|
|
fn placement(&self) -> Placement {
|
|
Placement::Ui
|
|
}
|
|
|
|
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
|
let decl: Decl = decode(POINT_ID, decl)?;
|
|
let path = &decl.path;
|
|
let mut theme = ThemeDef::parse(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
|
|
let mut files = vec![path.clone()];
|
|
if let Some(scheme) = &decl.editor_scheme {
|
|
files.push(scheme.clone());
|
|
theme.merge_editor_scheme(&pkg.read_text(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
|
|
}
|
|
let item = UiContribution {
|
|
plugin_id: pkg.id().to_owned(),
|
|
point: POINT_ID.into(),
|
|
key: theme.id.clone(),
|
|
decl: serde_json::to_value(&theme).map_err(|e| e.to_string())?,
|
|
};
|
|
let store = self.store.clone();
|
|
let mut prepared = Prepared::new(theme.name.clone(), move || Ok(store.insert(item))).key(theme.id);
|
|
prepared.files = files;
|
|
Ok(prepared)
|
|
}
|
|
}
|
|
|
|
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
|
|
/// parse errors still point at the right line.
|
|
pub(super) fn strip_json_comments(src: &str) -> String {
|
|
let mut out = String::with_capacity(src.len());
|
|
let mut chars = src.chars().peekable();
|
|
let (mut in_str, mut escaped) = (false, false);
|
|
while let Some(c) = chars.next() {
|
|
if in_str {
|
|
out.push(c);
|
|
if escaped {
|
|
escaped = false;
|
|
} else if c == '\\' {
|
|
escaped = true;
|
|
} else if c == '"' {
|
|
in_str = false;
|
|
}
|
|
continue;
|
|
}
|
|
match (c, chars.peek()) {
|
|
('/', Some('/')) => {
|
|
if chars.by_ref().any(|c| c == '\n') {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
('/', Some('*')) => {
|
|
chars.next();
|
|
let mut prev = ' ';
|
|
for c in chars.by_ref() {
|
|
if c == '\n' {
|
|
out.push('\n');
|
|
}
|
|
if prev == '*' && c == '/' {
|
|
break;
|
|
}
|
|
prev = c;
|
|
}
|
|
out.push(' ');
|
|
}
|
|
_ => {
|
|
in_str = c == '"';
|
|
out.push(c);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Values are plain CSS values (colours, lengths, font stacks). Anything that
|
|
/// could break out of the declaration or fetch resources is rejected.
|
|
fn is_safe_value(v: &str) -> bool {
|
|
let lower = v.to_ascii_lowercase();
|
|
!v.trim().is_empty()
|
|
&& v.len() <= MAX_VALUE_LEN
|
|
&& !v.chars().any(|c| matches!(c, ';' | '{' | '}' | '<' | '>' | '\\' | '\n' | '\r'))
|
|
&& !lower.contains("url(")
|
|
&& !lower.contains("image(")
|
|
&& !lower.contains("@import")
|
|
&& !lower.contains("expression(")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_and_validates() {
|
|
let t = ThemeDef::parse(r##"{"id":"sol","name":"Sol","kind":"dark","tokens":{"--bg-app":"#002b36","--font-ui":"'Inter', sans-serif"}}"##)
|
|
.unwrap();
|
|
assert_eq!(t.kind, ThemeKind::Dark);
|
|
assert_eq!(t.tokens["--bg-app"], "#002b36");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unsafe_tokens() {
|
|
let mk = |k: &str, v: &str| {
|
|
ThemeDef { id: "t".into(), name: "T".into(), kind: ThemeKind::Light, tokens: [(k.into(), v.into())].into() }
|
|
.validate()
|
|
};
|
|
assert!(mk("--bg", "red").is_ok());
|
|
assert!(mk("bg", "red").is_err());
|
|
assert!(mk("--Bg", "red").is_err());
|
|
assert!(mk("--bg", "red; color: blue").is_err());
|
|
assert!(mk("--bg", "url(https://evil)").is_err());
|
|
assert!(mk("--bg", "}body{").is_err());
|
|
assert!(mk("--bg", " ").is_err());
|
|
assert!(ThemeDef::parse("{\"id\":\"x\"}").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn allows_comments_outside_strings() {
|
|
let src = r##"{
|
|
// line comment
|
|
"id": "c", "name": "C // not a comment", "kind": "dark",
|
|
/* block
|
|
comment */
|
|
"tokens": { "--bg-app": "#000" /* inline */, "--font-ui": "\"a/*b\", sans-serif" }
|
|
}"##;
|
|
let t = ThemeDef::parse(src).unwrap();
|
|
assert_eq!(t.name, "C // not a comment");
|
|
assert_eq!(t.tokens["--font-ui"], "\"a/*b\", sans-serif");
|
|
assert!(ThemeDef::parse("{\n // x\n \"id\": 1\n}").unwrap_err().contains("line 3"));
|
|
}
|
|
|
|
#[test]
|
|
fn merges_editor_scheme_over_theme() {
|
|
let mut t = ThemeDef::parse(
|
|
r##"{"id":"m","name":"M","kind":"dark","tokens":{"--bg-app":"#111","--syn-keyword":"#222"}}"##,
|
|
)
|
|
.unwrap();
|
|
t.merge_editor_scheme(r##"{"name":"M", /* editor */ "tokens":{"--syn-keyword":"#333","--caret":"#fff"}}"##)
|
|
.unwrap();
|
|
assert_eq!(t.tokens["--bg-app"], "#111");
|
|
assert_eq!(t.tokens["--syn-keyword"], "#333");
|
|
assert_eq!(t.tokens["--caret"], "#fff");
|
|
assert!(t.clone().merge_editor_scheme(r#"{"tokens":{"--caret":"url(x)"}}"#).is_err());
|
|
assert!(t.merge_editor_scheme(r#"{"name":"no tokens"}"#).is_err());
|
|
}
|
|
}
|