diff --git a/Cargo.lock b/Cargo.lock index ec56cb175..62c74e7e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4272,6 +4272,7 @@ dependencies = [ "ide-workspace", "ignore", "parking_lot", + "regex-syntax", "reqwest", "rustix", "serde", diff --git a/Cargo.toml b/Cargo.toml index a1c42bf2e..cc6dc9d70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ zip = { version = "9", default-features = false, features = ["deflate"] } # search, index, language grep-searcher = "0.1" grep-regex = "0.1" +regex-syntax = "0.8" grep-matcher = "0.1" nucleo-matcher = "0.3" redb = "4" diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 5991a79f6..e34941471 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -351,6 +351,7 @@ pub struct AppearanceSettings { pub preferred_dark_theme: String, /// A plugin's file icon theme (`/`); empty = the built-in icons. pub file_icon_theme: String, + pub file_icon_theme_options: BTreeMap, /// IDE zoom in percent (50–300). pub zoom: u32, /// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). @@ -386,6 +387,24 @@ pub struct AppearanceSettings { /// Main toolbar entries per side, in order: title widget ids (`project`, /// `vcs.branch`…) or `action:`. Widgets in neither list nor `hidden` /// (registered later) keep their default place. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct FileIconThemeOptions { + pub groups: BTreeMap, + pub choices: BTreeMap, + pub hide_files: bool, + pub hide_folders: bool, + pub hollow_folders: bool, + pub hide_ui_icons: bool, + pub hide_psi_icons: bool, + pub monochrome: Option, + pub saturation: Option, + pub opacity: Option, + pub icon_size: Option, + pub row_height: Option, + pub accent: Option, +} + #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct MainToolbarSettings { @@ -406,6 +425,7 @@ impl Default for AppearanceSettings { preferred_light_theme: "light".into(), preferred_dark_theme: "dark".into(), file_icon_theme: String::new(), + file_icon_theme_options: BTreeMap::new(), zoom: 100, use_custom_font: false, ui_font_family: "Inter".into(), @@ -931,6 +951,36 @@ mod tests { assert_eq!(serde_json::from_value::(value).unwrap(), settings); } + #[test] + fn file_icon_theme_options_default_empty_and_round_trip() { + let old: Settings = serde_json::from_str(r#"{"appearance":{"fileIconTheme":"p/t"}}"#).unwrap(); + assert!(old.appearance.file_icon_theme_options.is_empty()); + let json = serde_json::json!({"appearance":{"fileIconThemeOptions":{"p/t":{ + "groups":{"tests":true},"choices":{"arrows":"plusminus"},"hideFiles":true,"hideFolders":false,"hollowFolders":true,"hideUiIcons":true,"hidePsiIcons":false, + "monochrome":"#546e7a","saturation":5,"opacity":80,"iconSize":20,"rowHeight":30,"accent":"#009688"}}}}); + let settings: Settings = serde_json::from_value(json.clone()).unwrap(); + let options = &settings.appearance.file_icon_theme_options["p/t"]; + assert!(options.hide_files && options.hollow_folders && !options.hide_folders); + assert!(options.hide_ui_icons && !options.hide_psi_icons); + assert!(options.groups["tests"]); + assert_eq!(options.choices["arrows"], "plusminus"); + assert_eq!( + (options.saturation, options.opacity, options.icon_size, options.row_height), + (Some(5), Some(80), Some(20), Some(30)) + ); + assert_eq!(options.monochrome.as_deref(), Some("#546e7a")); + assert_eq!(options.accent.as_deref(), Some("#009688")); + let value = serde_json::to_value(&settings).unwrap(); + let restored: Settings = serde_json::from_value(value).unwrap(); + assert_eq!(restored, settings); + let plain = serde_json::to_value(FileIconThemeOptions::default()).unwrap(); + assert_eq!(plain["hideFiles"], false); + assert_eq!(plain["hideUiIcons"], false); + assert_eq!(plain["hidePsiIcons"], false); + assert_eq!(plain["monochrome"], serde_json::Value::Null); + assert_eq!(plain["groups"], serde_json::json!({})); + } + #[test] fn ui_font_fallback_defaults_empty_and_round_trips() { let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap(); diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 5f7267d7d..cdb17dcf4 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -14,6 +14,7 @@ encoding_rs.workspace = true ide-core.workspace = true ide-text.workspace = true ide-vfs.workspace = true +regex-syntax.workspace = true ide-workspace.workspace = true ide-language.workspace = true ide-index.workspace = true diff --git a/crates/ide-host/src/plugins/icon_extensions.rs b/crates/ide-host/src/plugins/icon_extensions.rs new file mode 100644 index 000000000..fc970b6b0 --- /dev/null +++ b/crates/ide-host/src/plugins/icon_extensions.rs @@ -0,0 +1,207 @@ +use super::icon_patterns::validate_pattern; +use ide_extension::validate_item_id; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +const MAX_GROUPS: usize = 64; +const MAX_CHOICES: usize = 16; +const MAX_CHOICE_OPTIONS: usize = 16; +const MAX_NAME_LEN: usize = 255; +pub(super) const DARK_SUFFIX: &str = "_dark"; + +#[derive(Deserialize)] +#[serde(untagged)] +pub(super) enum IconDefinition { + Path(PathBuf), + Variants(IconVariants), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct IconVariants { + light: PathBuf, + #[serde(default)] + dark: Option, +} + +impl IconDefinition { + pub(super) fn light(&self) -> &PathBuf { + match self { + IconDefinition::Path(p) => p, + IconDefinition::Variants(v) => &v.light, + } + } + + pub(super) fn dark(&self) -> Option<&PathBuf> { + match self { + IconDefinition::Path(_) => None, + IconDefinition::Variants(v) => v.dark.as_ref(), + } + } +} + +#[derive(Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum Target { + #[default] + File, + Folder, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct Pattern { + pattern: String, + icon: String, + #[serde(default)] + target: Target, + #[serde(default)] + path: bool, + #[serde(default)] + ignore_case: bool, + #[serde(default)] + group: Option, + #[serde(default)] + open_icon: Option, +} + +fn enabled_by_default() -> bool { + true +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Group { + pub(super) id: String, + name: String, + #[serde(default = "enabled_by_default")] + enabled: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ChoiceOption { + pub(super) id: String, + pub(super) name: String, + #[serde(default)] + overrides: BTreeMap, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Choice { + pub(super) id: String, + pub(super) name: String, + pub(super) default: String, + pub(super) options: Vec, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Extensions { + #[serde(default)] + pub(super) patterns: Vec, + #[serde(default)] + pub(super) overrides: BTreeMap, + #[serde(default)] + pub(super) groups: Vec, + #[serde(default)] + pub(super) choices: Vec, + #[serde(default)] + pub(super) accent: bool, +} + +pub(super) fn check_icon_id(id: &str) -> Result<(), String> { + validate_item_id(id, "icon id")?; + if id.ends_with(DARK_SUFFIX) { + return Err(format!("icon id {id:?}: the {DARK_SUFFIX:?} suffix is reserved for dark variants")); + } + Ok(()) +} + +fn check_named(id: &str, name: &str, what: &str) -> Result<(), String> { + validate_item_id(id, what)?; + if name.trim().is_empty() || name.len() > MAX_NAME_LEN { + return Err(format!("{what} {id:?}: name must have 1 to {MAX_NAME_LEN} characters")); + } + Ok(()) +} + +fn check_unique<'a>(ids: impl Iterator, what: &str) -> Result<(), String> { + let mut seen = BTreeSet::new(); + match ids.into_iter().find(|id| !seen.insert(*id)) { + Some(id) => Err(format!("duplicate {what} {id:?}")), + None => Ok(()), + } +} + +impl Extensions { + pub(super) fn mappings(&self) -> usize { + let chosen: usize = self.choices.iter().flat_map(|c| &c.options).map(|o| o.overrides.len()).sum(); + self.patterns.len() + self.overrides.len() + chosen + } + + pub(super) fn validate(&self, check_ref: &dyn Fn(&str, &str) -> Result<(), String>) -> Result<(), String> { + self.validate_groups()?; + for p in &self.patterns { + validate_pattern(&p.pattern)?; + check_ref(&p.icon, "patterns")?; + if let Some(group) = &p.group + && !self.groups.iter().any(|g| &g.id == group) + { + return Err(format!("pattern {:?}: unknown group {group:?}", p.pattern)); + } + if let Some(open) = &p.open_icon { + if p.target != Target::Folder { + return Err(format!("pattern {:?}: openIcon is only for folder patterns", p.pattern)); + } + check_ref(open, "patterns")?; + } + } + check_overrides(&self.overrides, check_ref, "overrides")?; + for choice in &self.choices { + for option in &choice.options { + check_overrides(&option.overrides, check_ref, "choices")?; + } + } + Ok(()) + } + + fn validate_groups(&self) -> Result<(), String> { + if self.groups.len() > MAX_GROUPS || self.choices.len() > MAX_CHOICES { + return Err(format!("too many groups or choices (max {MAX_GROUPS} and {MAX_CHOICES})")); + } + for g in &self.groups { + check_named(&g.id, &g.name, "group")?; + } + check_unique(self.groups.iter().map(|g| &g.id), "group")?; + check_unique(self.choices.iter().map(|c| &c.id), "choice")?; + for choice in &self.choices { + check_named(&choice.id, &choice.name, "choice")?; + if choice.options.is_empty() || choice.options.len() > MAX_CHOICE_OPTIONS { + return Err(format!("choice {:?}: expected 1 to {MAX_CHOICE_OPTIONS} options", choice.id)); + } + for option in &choice.options { + check_named(&option.id, &option.name, "choice option")?; + } + check_unique(choice.options.iter().map(|o| &o.id), "choice option")?; + if !choice.options.iter().any(|o| o.id == choice.default) { + return Err(format!("choice {:?}: default {:?} is not one of its options", choice.id, choice.default)); + } + } + Ok(()) + } +} + +fn check_overrides( + overrides: &BTreeMap, + check_ref: &dyn Fn(&str, &str) -> Result<(), String>, + what: &str, +) -> Result<(), String> { + for (name, icon) in overrides { + validate_item_id(name, "overridden icon name")?; + check_ref(icon, what)?; + } + Ok(()) +} diff --git a/crates/ide-host/src/plugins/icon_patterns.rs b/crates/ide-host/src/plugins/icon_patterns.rs new file mode 100644 index 000000000..ef631e998 --- /dev/null +++ b/crates/ide-host/src/plugins/icon_patterns.rs @@ -0,0 +1,105 @@ +use regex_syntax::hir::{Hir, HirKind}; + +pub(crate) const MAX_PATTERN_LEN: usize = 512; +const NAME_LEN: u64 = 256; +const MAX_COST: u64 = 4 * NAME_LEN * NAME_LEN; + +const UNSUPPORTED: &[(&str, &str)] = &[ + ("\\A", "\\A"), + ("\\z", "\\z"), + ("\\Z", "\\Z"), + ("\\p", "\\p"), + ("\\P", "\\P"), + ("[[:", "a POSIX class"), + ("&&", "class intersection"), + ("~~", "class symmetric difference"), +]; + +pub(crate) fn validate_pattern(pattern: &str) -> Result<(), String> { + if pattern.len() > MAX_PATTERN_LEN { + return Err(format!("pattern {pattern:?} is too long (max {MAX_PATTERN_LEN})")); + } + if let Some(construct) = unsupported_construct(pattern) { + return Err(format!("pattern {pattern:?}: {construct} is not supported")); + } + let hir = regex_syntax::Parser::new().parse(pattern).map_err(|e| format!("invalid pattern {pattern:?}: {e}"))?; + match cost(&hir) { + Err(why) => Err(format!("pattern {pattern:?}: {why} is not supported")), + Ok(c) if c > MAX_COST => Err(format!("pattern {pattern:?} is too complex: too many ways to match one name")), + Ok(_) => Ok(()), + } +} + +fn unsupported_construct(pattern: &str) -> Option<&'static str> { + if pattern.match_indices("(?").any(|(at, _)| !pattern[at + 2..].starts_with(':')) { + return Some("an inline flag, lookaround or named group"); + } + UNSUPPORTED.iter().find(|(needle, _)| pattern.contains(needle)).map(|(_, what)| *what) +} + +fn cost(hir: &Hir) -> Result { + match hir.kind() { + HirKind::Empty | HirKind::Literal(_) | HirKind::Class(_) | HirKind::Look(_) => Ok(1), + HirKind::Capture(c) => cost(&c.sub), + HirKind::Concat(items) => items.iter().try_fold(1u64, |acc, h| Ok(acc.saturating_mul(cost(h)?))), + HirKind::Alternation(items) => items.iter().try_fold(0u64, |acc, h| Ok(acc.saturating_add(cost(h)?))), + HirKind::Repetition(r) => match r.max { + None if matches!(r.sub.kind(), HirKind::Literal(_) | HirKind::Class(_)) => Ok(NAME_LEN), + None => Err("a repeated group (`(…)*`, `(…)+`)"), + Some(max) => Ok(cost(&r.sub)?.saturating_add(1).saturating_pow(max)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn err(pattern: &str) -> String { + validate_pattern(pattern).unwrap_err() + } + + #[test] + fn accepts_typical_file_name_patterns() { + for pattern in [ + r"^(architecture|ARCHITECTURE)(\.(md|rst|txt))?$", + r"(main|workflow|ci|release|build|config)\.ya?ml$", + r"^[a-z]+\.d\.ts$", + r"^\.?eslintrc(\.(js|cjs|json|ya?ml))?$", + r"^.*\.config\.[cm]?[jt]s$", + r"^.*-(test|spec)\..*$", + r"^[0-9]{4}-[0-9]{2}\.log$", + ] { + assert!(validate_pattern(pattern).is_ok(), "{pattern}: {:?}", validate_pattern(pattern)); + } + } + + #[test] + fn rejects_patterns_that_backtrack_catastrophically() { + assert!(err(r"(a+)+$").contains("repeated group")); + assert!(err(r"(?:a*)*").contains("repeated group")); + assert!(err(r"^(\w|\w\w)*!$").contains("repeated group")); + assert!(err(r"^(a|aa)+$").contains("repeated group")); + assert!(err(r"^.*.*.*x$").contains("too complex")); + assert!(err(r"^(a?){30}a{30}$").contains("too complex")); + assert!(err(&format!("^{}b$", "(a|aa)".repeat(20))).contains("too complex")); + assert!(err(r"x{0,40}").contains("too complex")); + } + + #[test] + fn rejects_constructs_javascript_reads_differently() { + assert!(err(r"(?i)readme").contains("not supported")); + assert!(err(r"(?Pa)").contains("not supported")); + assert!(err(r"\Areadme").contains("not supported")); + assert!(err(r"readme\z").contains("not supported")); + assert!(err(r"\p{L}").contains("not supported")); + assert!(err(r"[[:alpha:]]").contains("not supported")); + assert!(err(r"[a-z&&[^x]]").contains("not supported")); + } + + #[test] + fn rejects_invalid_and_oversized_patterns() { + assert!(err("(").contains("invalid pattern")); + assert!(err(&"a".repeat(MAX_PATTERN_LEN + 1)).contains("too long")); + } +} diff --git a/crates/ide-host/src/plugins/icon_settings.rs b/crates/ide-host/src/plugins/icon_settings.rs new file mode 100644 index 000000000..b1cbbf453 --- /dev/null +++ b/crates/ide-host/src/plugins/icon_settings.rs @@ -0,0 +1,185 @@ +use super::icon_extensions::Extensions; +use serde::{Deserialize, Serialize}; + +const MAX_SECTIONS: usize = 16; +const MAX_ITEMS: usize = 32; +const MAX_TEXT_LEN: usize = 255; +const MAX_HINT_LEN: usize = 512; +const MAX_COLUMNS: u8 = 4; + +#[derive(Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum SettingOption { + HideFiles, + HideFolders, + HideUiIcons, + HidePsiIcons, + HollowFolders, + Groups, + Choice, + Accent, + Monochrome, + Saturation, + Opacity, + IconSize, + RowHeight, +} + +#[derive(Deserialize, Serialize)] +#[serde(untagged)] +pub(super) enum SettingDefault { + Number(u8), + Color(String), +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct SettingItem { + option: SettingOption, + #[serde(default)] + label: Option, + #[serde(default)] + hint: Option, + #[serde(default)] + invert: bool, + #[serde(default)] + choice: Option, + #[serde(default)] + columns: Option, + #[serde(default)] + default: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SettingsSection { + title: String, + items: Vec, +} + +#[derive(Serialize)] +struct ChoiceSummary<'a> { + id: &'a str, + name: &'a str, + default: &'a str, + options: Vec>, +} + +#[derive(Serialize)] +struct OptionSummary<'a> { + id: &'a str, + name: &'a str, +} + +fn check_text(text: &str, max: usize, what: &str) -> Result<(), String> { + if text.trim().is_empty() || text.len() > max { + return Err(format!("settings: {what} must have 1 to {max} characters")); + } + Ok(()) +} + +fn range(option: SettingOption) -> Option<(u8, u8)> { + match option { + SettingOption::Saturation => Some((0, 20)), + SettingOption::Opacity => Some((10, 100)), + SettingOption::IconSize => Some((12, 32)), + SettingOption::RowHeight => Some((16, 48)), + _ => None, + } +} + +fn is_color(text: &str) -> bool { + text.len() == 7 && text.starts_with('#') && text[1..].chars().all(|c| c.is_ascii_hexdigit()) +} + +fn is_flag(option: SettingOption) -> bool { + matches!( + option, + SettingOption::HideFiles + | SettingOption::HideFolders + | SettingOption::HideUiIcons + | SettingOption::HidePsiIcons + | SettingOption::HollowFolders + ) +} + +impl SettingItem { + fn validate(&self, ext: &Extensions) -> Result<(), String> { + let named = !matches!(self.option, SettingOption::Groups | SettingOption::Choice); + match &self.label { + Some(label) => check_text(label, MAX_TEXT_LEN, "label")?, + None if named => return Err("settings: label is required".into()), + None => {} + } + if let Some(hint) = &self.hint { + check_text(hint, MAX_HINT_LEN, "hint")?; + } + if self.invert && !is_flag(self.option) { + return Err("settings: invert is only for on/off options".into()); + } + if self.columns.is_some_and(|c| c == 0 || c > MAX_COLUMNS) + || (self.columns.is_some() && self.option != SettingOption::Groups) + { + return Err(format!("settings: columns are 1 to {MAX_COLUMNS}, for groups only")); + } + match (self.option, &self.choice) { + (SettingOption::Choice, Some(id)) if !ext.choices.iter().any(|c| &c.id == id) => { + return Err(format!("settings: unknown choice {id:?}")); + } + (SettingOption::Choice, None) => return Err("settings: choice items name their choice".into()), + (SettingOption::Choice, Some(_)) | (_, None) => {} + (_, Some(_)) => return Err("settings: only choice items name a choice".into()), + } + if self.option == SettingOption::Accent && !ext.accent { + return Err("settings: the accent option needs \"accent\": true".into()); + } + self.validate_default() + } + + fn validate_default(&self) -> Result<(), String> { + let colored = matches!(self.option, SettingOption::Accent | SettingOption::Monochrome); + match (range(self.option), &self.default) { + (Some((min, max)), Some(SettingDefault::Number(n))) if (min..=max).contains(n) => Ok(()), + (Some((min, max)), _) => Err(format!("settings: a default from {min} to {max} is required")), + (None, Some(SettingDefault::Color(c))) if colored && is_color(c) => Ok(()), + (None, _) if colored => Err("settings: a #rrggbb default is required".into()), + (None, None) => Ok(()), + (None, Some(_)) => Err("settings: this option takes no default".into()), + } + } +} + +pub(super) fn validate(sections: &[SettingsSection], ext: &Extensions) -> Result<(), String> { + if sections.len() > MAX_SECTIONS { + return Err(format!("settings: more than {MAX_SECTIONS} sections")); + } + for section in sections { + check_text(§ion.title, MAX_TEXT_LEN, "title")?; + if section.items.len() > MAX_ITEMS { + return Err(format!("settings: more than {MAX_ITEMS} items in {:?}", section.title)); + } + for item in §ion.items { + item.validate(ext)?; + } + } + Ok(()) +} + +pub(super) fn summary(sections: &[SettingsSection], ext: &Extensions) -> serde_json::Value { + let choices: Vec = ext + .choices + .iter() + .map(|c| ChoiceSummary { + id: &c.id, + name: &c.name, + default: &c.default, + options: c.options.iter().map(|o| OptionSummary { id: &o.id, name: &o.name }).collect(), + }) + .collect(); + serde_json::json!({ + "groups": ext.groups, + "choices": choices, + "accent": ext.accent, + "settings": sections, + }) +} diff --git a/crates/ide-host/src/plugins/icons.rs b/crates/ide-host/src/plugins/icons.rs index 729bf6266..60b1ac2e0 100644 --- a/crates/ide-host/src/plugins/icons.rs +++ b/crates/ide-host/src/plugins/icons.rs @@ -24,6 +24,8 @@ //! asset; the client fetches the mapping and the icons only when the theme is //! selected, so installed icon themes cost nothing at start-up. +use super::icon_extensions::{Choice, DARK_SUFFIX, Extensions, Group, IconDefinition, Pattern, check_icon_id}; +use super::icon_settings::{self, SettingsSection}; use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id}; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; @@ -45,7 +47,7 @@ struct Decl { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ThemeFile { - icon_definitions: BTreeMap, + icon_definitions: BTreeMap, #[serde(default)] file: Option, #[serde(default)] @@ -53,11 +55,25 @@ struct ThemeFile { #[serde(default)] folder_expanded: Option, #[serde(default)] + folder_open: Option, + #[serde(default)] file_extensions: BTreeMap, #[serde(default)] file_names: BTreeMap, #[serde(default)] folder_names: BTreeMap, + #[serde(default)] + patterns: Vec, + #[serde(default)] + overrides: BTreeMap, + #[serde(default)] + groups: Vec, + #[serde(default)] + choices: Vec, + #[serde(default)] + accent: bool, + #[serde(default)] + settings: Vec, } /// Asset id of a theme's mapping (JSON). @@ -65,6 +81,10 @@ pub fn map_asset(theme: &str) -> String { format!("iconTheme/{theme}/map") } +pub fn settings_asset(theme: &str) -> String { + format!("iconTheme/{theme}/settings") +} + /// Asset id prefix of a theme's SVGs (`…/svg/`). pub fn svg_prefix(theme: &str) -> String { format!("iconTheme/{theme}/svg/") @@ -87,12 +107,20 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result MAX_ICONS { return Err(format!("{}: more than {MAX_ICONS} icons", d.path.display())); } - let mappings = theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len(); + let mappings = + theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len() + extensions.mappings(); if mappings > MAX_MAPPINGS { return Err(format!("{}: more than {MAX_MAPPINGS} mappings", d.path.display())); } @@ -100,7 +128,17 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result Result<(), String> { if defined.contains(icon) { Ok(()) } else { Err(format!("{}: unknown icon {icon:?}", at(what))) } }; - for (what, icon) in [("file", &theme.file), ("folder", &theme.folder), ("folderExpanded", &theme.folder_expanded)] { + for icon in theme.icon_definitions.keys() { + check_icon_id(icon)?; + } + extensions.validate(&check_ref)?; + icon_settings::validate(&theme.settings, &extensions).map_err(|e| format!("{}: {e}", d.path.display()))?; + for (what, icon) in [ + ("file", &theme.file), + ("folder", &theme.folder), + ("folderExpanded", &theme.folder_expanded), + ("folderOpen", &theme.folder_open), + ] { if let Some(icon) = icon { check_ref(icon, what)?; } @@ -125,20 +163,31 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result"#; + + fn theme_package(theme: &str) -> (tempfile::TempDir, Package) { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::create_dir_all(dir.join("icons")).unwrap(); + for icon in ["file", "folder", "folder-src", "folder-src-open", "rust", "rust-dark", "tests", "arrow"] { + std::fs::write(dir.join(format!("icons/{icon}.svg")), SVG).unwrap(); + } + std::fs::write(dir.join("theme.json"), theme).unwrap(); + let manifest = ide_extension::Manifest::parse( + "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"", + ) + .unwrap(); + let pkg = Package::new(manifest, dir.to_path_buf()); + (tmp, pkg) + } + + fn load(theme: &str) -> Result { + let (_tmp, pkg) = theme_package(theme); + let mut t = toml::Table::new(); + t.insert("id".into(), "m".into()); + t.insert("label".into(), "M".into()); + t.insert("path".into(), "theme.json".into()); + icon_theme(&pkg, toml::Value::Table(t)) + } + + fn with(extra: &str) -> String { + format!( + r#"{{"iconDefinitions":{{"file":"icons/file.svg","folder":"icons/folder.svg", + "folder-src":"icons/folder-src.svg","folder-src-open":"icons/folder-src-open.svg", + "rust":{{"light":"icons/rust.svg","dark":"icons/rust-dark.svg"}}, + "tests":"icons/tests.svg","arrow":"icons/arrow.svg"}}, + "file":"file","folder":"folder"{extra}}}"# + ) + } + + const EXTENSIONS: &str = r#","patterns":[ + {"pattern":"^.*\\.rs$","icon":"rust","ignoreCase":true}, + {"pattern":"/tests?/","icon":"tests","path":true,"group":"tests"}, + {"pattern":"^src$","icon":"folder-src","target":"folder","openIcon":"folder-src-open"}], + "overrides":{"chevron-right":"arrow","symbol-class":"rust"}, + "groups":[{"id":"tests","name":"Tests","enabled":false}], + "choices":[{"id":"arrows","name":"Arrows","default":"plain","options":[ + {"id":"plain","name":"Default"},{"id":"material","name":"Material","overrides":{"chevron-down":"arrow"}}]}], + "accent":true,"folderOpen":"folder-src-open""#; + + fn map_of(d: &UiDecl) -> serde_json::Value { + serde_json::from_str(&d.assets.iter().find(|(k, _)| *k == map_asset("m")).unwrap().1).unwrap() + } + + #[test] + fn theme_extensions_reach_the_mapping() { + let d = load(&with(EXTENSIONS)).unwrap(); + let map = map_of(&d); + assert_eq!( + map["patterns"], + serde_json::json!([ + {"pattern":"^.*\\.rs$","icon":"rust","target":"file","path":false,"ignoreCase":true,"group":null,"openIcon":null}, + {"pattern":"/tests?/","icon":"tests","target":"file","path":true,"ignoreCase":false,"group":"tests","openIcon":null}, + {"pattern":"^src$","icon":"folder-src","target":"folder","path":false,"ignoreCase":false,"group":null,"openIcon":"folder-src-open"} + ]) + ); + assert_eq!(map["overrides"], serde_json::json!({"chevron-right":"arrow","symbol-class":"rust"})); + assert_eq!(map["groups"], serde_json::json!([{"id":"tests","name":"Tests","enabled":false}])); + assert_eq!(map["choices"][0]["default"], "plain"); + assert_eq!(map["choices"][0]["options"][1]["overrides"], serde_json::json!({"chevron-down":"arrow"})); + assert_eq!(map["choices"][0]["options"][0]["overrides"], serde_json::json!({})); + assert_eq!(map["accent"], true); + assert_eq!(map["folderOpen"], "folder-src-open"); + assert_eq!(d.json["icons"], 7); + assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust", svg_prefix("m")))); + assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust_dark", svg_prefix("m")))); + assert!(d.files.iter().any(|f| f.ends_with("icons/rust-dark.svg"))); + } + + #[test] + fn themes_without_extensions_keep_their_mapping() { + let map = map_of(&load(&with("")).unwrap()); + assert_eq!(map["patterns"], serde_json::json!([])); + assert_eq!(map["overrides"], serde_json::json!({})); + assert_eq!(map["groups"], serde_json::json!([])); + assert_eq!(map["choices"], serde_json::json!([])); + assert_eq!(map["accent"], false); + } + + #[test] + fn theme_extensions_are_validated() { + let cases = [ + (r#","patterns":[{"pattern":"x","icon":"nope"}]"#, "unknown icon"), + (r#","patterns":[{"pattern":"(a+)+$","icon":"rust"}]"#, "repeated group"), + (r#","patterns":[{"pattern":"(","icon":"rust"}]"#, "invalid pattern"), + (r#","patterns":[{"pattern":"x","icon":"rust","group":"nope"}]"#, "unknown group"), + (r#","patterns":[{"pattern":"x","icon":"rust","openIcon":"tests"}]"#, "openIcon"), + (r#","patterns":[{"pattern":"x","icon":"folder","target":"folder","openIcon":"nope"}]"#, "unknown icon"), + (r#","patterns":[{"pattern":"x","icon":"rust","priority":1}]"#, "unknown field"), + (r#","overrides":{"chevron-right":"nope"}"#, "unknown icon"), + (r#","folderOpen":"nope""#, "unknown icon"), + (r#","overrides":{"Chevron":"rust"}"#, "invalid overridden icon"), + (r#","groups":[{"id":"a","name":" "}]"#, "name"), + (r#","groups":[{"id":"a","name":"A"},{"id":"a","name":"B"}]"#, "duplicate"), + (r#","choices":[{"id":"c","name":"C","default":"x","options":[{"id":"y","name":"Y"}]}]"#, "default"), + (r#","choices":[{"id":"c","name":"C","default":"y","options":[]}]"#, "options"), + ( + r#","choices":[{"id":"c","name":"C","default":"y","options":[{"id":"y","name":"Y","overrides":{"a":"nope"}}]}]"#, + "unknown icon", + ), + ]; + for (extra, needle) in cases { + let err = load(&with(extra)).err().unwrap_or_else(|| panic!("accepted: {extra}")); + assert!(err.contains(needle), "{extra}: {err}"); + } + let dark_id = r#"{"iconDefinitions":{"x_dark":"icons/rust.svg"}}"#; + assert!(load(dark_id).unwrap_err().contains("_dark")); + } + + const SETTINGS: &str = r##","settings":[ + {"title":"Toggle","items":[ + {"option":"hideFiles","label":"Enable file icons","hint":"Files","invert":true}, + {"option":"hollowFolders","label":"Hollow"}]}, + {"title":"Packs","items":[{"option":"groups","columns":2}]}, + {"title":"Other","items":[ + {"option":"choice","choice":"arrows"}, + {"option":"accent","label":"Accent","default":"#009688"}, + {"option":"saturation","label":"Saturation","default":10}]}]"##; + + fn settings_of(d: &UiDecl) -> Option { + d.assets.iter().find(|(k, _)| *k == settings_asset("m")).map(|(_, v)| serde_json::from_str(v).unwrap()) + } + + #[test] + fn theme_settings_get_their_own_asset() { + let d = load(&with(&format!("{EXTENSIONS}{SETTINGS}"))).unwrap(); + let s = settings_of(&d).unwrap(); + assert_eq!(s["accent"], true); + assert_eq!(s["groups"], serde_json::json!([{"id":"tests","name":"Tests","enabled":false}])); + assert_eq!( + s["choices"], + serde_json::json!([{"id":"arrows","name":"Arrows","default":"plain","options":[ + {"id":"plain","name":"Default"},{"id":"material","name":"Material"}]}]) + ); + assert_eq!(s["settings"][0]["title"], "Toggle"); + assert_eq!( + s["settings"][0]["items"][0], + serde_json::json!({"option":"hideFiles","label":"Enable file icons","hint":"Files","invert":true, + "choice":null,"columns":null,"default":null}) + ); + assert_eq!(s["settings"][2]["items"][1]["default"], "#009688"); + assert_eq!(s["settings"][2]["items"][2]["default"], 10); + assert!(settings_of(&load(&with(EXTENSIONS)).unwrap()).is_none()); + } + + #[test] + fn theme_settings_are_validated() { + let section = |items: &str| with(&format!(r#"{EXTENSIONS},"settings":[{{"title":"T","items":[{items}]}}]"#)); + let cases = [ + (section(r#"{"option":"nope","label":"X"}"#), "unknown variant"), + (section(r#"{"option":"hideFiles"}"#), "label"), + (section(r#"{"option":"hideFiles","label":" "}"#), "label"), + (section(r#"{"option":"choice","choice":"nope"}"#), "unknown choice"), + (section(r#"{"option":"choice"}"#), "choice"), + (section(r#"{"option":"groups","columns":9}"#), "columns"), + (section(r#"{"option":"saturation","label":"S"}"#), "default"), + (section(r#"{"option":"saturation","label":"S","default":50}"#), "default"), + (section(r##"{"option":"opacity","label":"O","default":"#000000"}"##), "default"), + (section(r#"{"option":"monochrome","label":"M","default":"red"}"#), "default"), + (section(r#"{"option":"saturation","label":"S","default":5,"invert":true}"#), "invert"), + (section(r#"{"option":"hideFiles","label":"F","extra":1}"#), "unknown field"), + (with(&format!(r#"{EXTENSIONS},"settings":[{{"title":"","items":[]}}]"#)), "title"), + ( + with(r##","settings":[{"title":"T","items":[{"option":"accent","label":"A","default":"#000000"}]}]"##), + "accent", + ), + ]; + for (theme, needle) in cases { + let err = load(&theme).err().unwrap_or_else(|| panic!("accepted: {theme}")); + assert!(err.contains(needle), "{theme}: {err}"); + } + } } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index bc0beba14..631bec5ea 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -16,6 +16,9 @@ //! projects) and folders linked with `openidea-plugin link` (`links.json`). mod api; +mod icon_extensions; +mod icon_patterns; +mod icon_settings; mod icons; mod install; mod market; diff --git a/plugins/README.md b/plugins/README.md index 4e994f5ec..5bc1d385e 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -585,6 +585,84 @@ attributes only, no scripts, event handlers, external references, `url()` or entities. Apply a theme in Settings › Plugins; see [`official/material-icons`](official/material-icons) for a complete one. +Optional fields: + +```json +{ + "iconDefinitions": { "rust": { "light": "icons/rust.svg", "dark": "icons/rust_dark.svg" } }, + "folderOpen": "folder-open", + "patterns": [ + { "pattern": "^.*\\.spec\\.ts$", "icon": "test-ts", "ignoreCase": true }, + { "pattern": "/fixtures/", "icon": "fixture", "path": true, "group": "tests" }, + { "pattern": "^src$", "icon": "folder-src", "target": "folder", "openIcon": "folder-src-open" } + ], + "overrides": { "chevron-right": "arrow-right", "symbol-class": "class" }, + "groups": [{ "id": "tests", "name": "Tests", "enabled": false }], + "choices": [ + { + "id": "arrows", "name": "Arrows", "default": "plain", + "options": [ + { "id": "plain", "name": "Default" }, + { "id": "plus", "name": "Plus-Minus", "overrides": { "chevron-right": "plus" } } + ] + } + ], + "accent": true, + "settings": [ + { + "title": "Icons", + "items": [ + { "option": "hideFiles", "invert": true, "label": "Show file icons", "hint": "In trees, lists and tabs" }, + { "option": "groups", "columns": 2 }, + { "option": "choice", "choice": "arrows" }, + { "option": "iconSize", "label": "Icon size", "default": 16 } + ] + } + ] +} +``` + +- `iconDefinitions` values may be `{ "light", "dark" }`; the dark SVG is used + in dark themes. Icon ids may not end in `_dark`. +- `patterns` are regular expressions checked in order, before `fileNames` + and `fileExtensions`. `target` is `file` (default) or `folder`; with `path` + a pattern matches the whole path (`/` separators) instead of the name; + `ignoreCase` ignores case. Patterns that could backtrack badly (a repeated + group, too many ways to match one name), lookaround, inline flags and + named groups are refused. +- `openIcon` (folder patterns) and `folderOpen` (other folders) are used for + folders holding a file open in the editor when _Hollow folders_ is on. +- `overrides` replace built-in IDE icons by name (`chevron-right`, + `folder-source`, …); symbol icons (`symbol-`, `class`, `method`, …) + are PSI icons. +- `groups` are packs of patterns the user turns on or off; `enabled` is the + default. +- `choices` are variants the user picks one of; the picked option's + `overrides` win over `overrides`. +- `accent: true`: elements with `data-tint="true"` take the colour the user + picks. +- Elements with `data-themed="true"` take the accent colour of Settings › + Appearance, or the theme's custom accent colour. + +- `settings` describe the theme's page in Settings › Plugins › _theme label_; + a theme without them has no page. Sections have a `title` and `items`; an + item names an `option` the IDE implements, with the theme's `label`, + optional `hint` and, where the option needs one, `default`: + + | `option` | Control | Item fields | + | -------------------------------------------------------------------------- | -------------------- | ---------------------------------------------------- | + | `hideFiles`, `hideFolders`, `hideUiIcons`, `hidePsiIcons`, `hollowFolders` | checkbox | `invert`: checked means off | + | `groups` | a checkbox per group | `columns` (1–4), `label?` (group names are shown) | + | `choice` | the choice's options | `choice` (its id), `label?` (default: its name) | + | `accent`, `monochrome` | checkbox and colour | `default` (`#rrggbb`); `accent` needs `accent: true` | + | `saturation` (0–20), `opacity` (10–100), `iconSize`, `rowHeight` (px) | checkbox and number | `default` within the range | + + `iconSize` is 12–32 and `rowHeight` 16–48. Values are kept per theme in + `appearance.fileIconThemeOptions` and apply while the theme is selected. + +The first icon theme of a plugin is selected when the plugin is installed or +enabled while no icon theme is. + ## 6. Languages `languages/.toml` files use the IDE's own language format; see any file in diff --git a/src/bindings.ts b/src/bindings.ts index 59e383497..1a629c7ad 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1596,6 +1596,7 @@ export type AppearanceSettings = { preferredDarkTheme?: string, /** A plugin's file icon theme (`/`); empty = the built-in icons. */ fileIconTheme?: string, + fileIconThemeOptions?: { [key in string]: FileIconThemeOptions }, /** IDE zoom in percent (50–300). */ zoom?: number, /** Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). */ @@ -3381,6 +3382,27 @@ export type FileEntry = { targetKind?: EntryKind | null, }; +/** + * Main toolbar entries per side, in order: title widget ids (`project`, + * `vcs.branch`…) or `action:`. Widgets in neither list nor `hidden` + * (registered later) keep their default place. + */ +export type FileIconThemeOptions = { + groups?: { [key in string]: boolean }, + choices?: { [key in string]: string }, + hideFiles?: boolean, + hideFolders?: boolean, + hollowFolders?: boolean, + hideUiIcons?: boolean, + hidePsiIcons?: boolean, + monochrome?: string | null, + saturation?: number | null, + opacity?: number | null, + iconSize?: number | null, + rowHeight?: number | null, + accent?: string | null, +}; + /** Identifier occurrences in one file (`identifiers_in_files`). */ export type FileIdentifiers = { path: IpcPath, @@ -4735,11 +4757,6 @@ export type LogQuery = { export type LspRefreshKind = "inlayHints" | "semanticTokens" | "codeLens"; -/** - * Main toolbar entries per side, in order: title widget ids (`project`, - * `vcs.branch`…) or `action:`. Widgets in neither list nor `hidden` - * (registered later) keep their default place. - */ export type MainToolbarSettings = { left?: string[], right?: string[], diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 18ffcb05d..ced5ea783 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -193,7 +193,7 @@ export function tabTitle(input: EditorInput, kinds: EditorKinds): string { } export function tabIcon(input: EditorInput, kinds: EditorKinds): string { - if (input.kind === "file") return fileIconName(basename(input.path ?? "")); + if (input.kind === "file") return fileIconName(basename(input.path ?? ""), input.path ?? null); if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds)); const kind = kinds.get(input.kind); try { diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index 25986f0db..2c8288efd 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -488,7 +488,7 @@ function DefinitionPopup({ popup }: { popup: Extract
- + {many ? ( ({ value: o.id, label: o.name }))} + onChange={(value) => set({ choices: { ...options.choices, [choice.id]: value } })} + /> + + ); +} + +function ColourItem({ item, options, set, colour }: ItemProps & { colour: Colour }) { + const label = item.label ?? ""; + const value = options[colour]; + const fallback = String(item.default ?? ""); + return ( + <> + + set({ [colour]: on ? fallback : null })} /> + {value && ( + set({ [colour]: v })} + aria-label={label} + /> + )} + + + + ); +} + +function AmountItem({ item, options, set, amount }: ItemProps & { amount: Amount }) { + const label = item.label ?? ""; + const value = options[amount]; + const fallback = Number(item.default ?? ICON_THEME_LIMITS[amount].min); + return ( + <> + + set({ [amount]: on ? fallback : null })} /> + {value != null && ( + set({ [amount]: v })} + /> + )} + + + + ); +} + +function Item(props: ItemProps) { + const { option } = props.item; + switch (option) { + case "hideFiles": + case "hideFolders": + case "hideUiIcons": + case "hidePsiIcons": + case "hollowFolders": + return ; + case "groups": + return ; + case "choice": + return ; + case "accent": + case "monochrome": + return ; + case "saturation": + case "opacity": + case "iconSize": + case "rowHeight": + return ; + } +} + +export function IconThemeSettings({ themeKey, details }: { themeKey: string; details: IconThemeDetails }) { + const [all, setAll] = useSetting | undefined>("appearance.fileIconThemeOptions"); + const options = validateIconThemeOptions(all?.[themeKey]); + const set = (patch: Partial) => setAll({ ...all, [themeKey]: { ...options, ...patch } }); + return details.settings.map((section, i) => ( +
+ {section.items.map((item, j) => ( + + ))} +
+ )); +} diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index a50adc30c..48e74712f 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -260,7 +260,7 @@ function RollbackDialog({ files, onClose }: { files: FileStatus[]; onClose: () = }) } /> - + {baseName(f.path)} diff --git a/src/features/vcs/conflictsUi.tsx b/src/features/vcs/conflictsUi.tsx index 2777e3eab..caf743b60 100644 --- a/src/features/vcs/conflictsUi.tsx +++ b/src/features/vcs/conflictsUi.tsx @@ -126,7 +126,7 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) { }} renderItem={(f) => ( - + {baseName(f.path)} diff --git a/src/features/vcs/patches.tsx b/src/features/vcs/patches.tsx index 0f4ee0820..8e43c0e9a 100644 --- a/src/features/vcs/patches.tsx +++ b/src/features/vcs/patches.tsx @@ -305,7 +305,7 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s f.applies ? tx("Applies cleanly") : tx("Does not apply cleanly: a 3-way merge will be attempted") } > - + - + {baseName(stripSlash(f.path))} @@ -517,7 +517,7 @@ export function ShelfPanel() { renderNode={(n) => n.file ? ( <> - + , IconName> = { diff --git a/src/platform/iconThemeOptions.ts b/src/platform/iconThemeOptions.ts new file mode 100644 index 000000000..01b9abc24 --- /dev/null +++ b/src/platform/iconThemeOptions.ts @@ -0,0 +1,58 @@ +import type { FileIconThemeOptions } from "@/bindings"; + +export const ICON_THEME_LIMITS = { + saturation: { min: 0, max: 20 }, + opacity: { min: 10, max: 100 }, + iconSize: { min: 12, max: 32 }, + rowHeight: { min: 16, max: 48 }, +} as const; + +type Limited = keyof typeof ICON_THEME_LIMITS; + +const THEME_KEY = /^[^/\s]+\/[^/\s]+$/; +const RGB = /^#[0-9a-fA-F]{6}$/; + +type Obj = Record; +const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {}); +const isObj = (v: unknown) => !!v && typeof v === "object" && !Array.isArray(v); + +function only(raw: unknown, keep: (v: unknown) => v is T): Record { + return Object.fromEntries(Object.entries(obj(raw)).filter((e): e is [string, T] => keep(e[1]))); +} + +const isBool = (v: unknown): v is boolean => typeof v === "boolean"; +const isString = (v: unknown): v is string => typeof v === "string"; +const color = (v: unknown) => (typeof v === "string" && RGB.test(v) ? v : null); + +function limited(v: unknown, key: Limited): number | null { + if (typeof v !== "number" || !Number.isFinite(v)) return null; + const { min, max } = ICON_THEME_LIMITS[key]; + return Math.round(Math.min(max, Math.max(min, v))); +} + +export function validateIconThemeOptions(raw: unknown): FileIconThemeOptions { + const o = obj(raw); + return { + groups: only(o.groups, isBool), + choices: only(o.choices, isString), + hideFiles: o.hideFiles === true, + hideFolders: o.hideFolders === true, + hollowFolders: o.hollowFolders === true, + hideUiIcons: o.hideUiIcons === true, + hidePsiIcons: o.hidePsiIcons === true, + monochrome: color(o.monochrome), + saturation: limited(o.saturation, "saturation"), + opacity: limited(o.opacity, "opacity"), + iconSize: limited(o.iconSize, "iconSize"), + rowHeight: limited(o.rowHeight, "rowHeight"), + accent: color(o.accent), + }; +} + +export function validateIconThemeOptionsMap(raw: unknown): Record { + return Object.fromEntries( + Object.entries(obj(raw)) + .filter(([key, v]) => THEME_KEY.test(key) && isObj(v)) + .map(([key, v]) => [key, validateIconThemeOptions(v)]), + ); +} diff --git a/src/platform/themes.ts b/src/platform/themes.ts index 9eaa5ad68..3454f8937 100644 --- a/src/platform/themes.ts +++ b/src/platform/themes.ts @@ -102,11 +102,63 @@ export function baseTheme(id: ThemeChoiceId, fallback: BuiltinThemeId = "light") // --- file icon themes (plugins) ------------------------------------------------- +export interface IconThemePack { + id: string; + name: string; + enabled: boolean; +} + +export interface IconThemeVariant { + id: string; + name: string; + default: string; + options: readonly { id: string; name: string }[]; +} + +export type IconThemeSettingOption = + | "hideFiles" + | "hideFolders" + | "hideUiIcons" + | "hidePsiIcons" + | "hollowFolders" + | "groups" + | "choice" + | "accent" + | "monochrome" + | "saturation" + | "opacity" + | "iconSize" + | "rowHeight"; + +export interface IconThemeSettingItem { + option: IconThemeSettingOption; + label?: string | null; + hint?: string | null; + invert?: boolean; + choice?: string | null; + columns?: number | null; + default?: number | string | null; +} + +export interface IconThemeSettingsSection { + title: string; + items: readonly IconThemeSettingItem[]; +} + +export interface IconThemeDetails { + groups: readonly IconThemePack[]; + choices: readonly IconThemeVariant[]; + accent: boolean; + settings: readonly IconThemeSettingsSection[]; +} + export interface IconThemeChoice { /** `/`: the `appearance.fileIconTheme` value. */ id: string; label: string; origin?: string; + settingsPage?: string; + details?: () => Promise; } /** File icon themes of live plugins, for Settings › Appearance. */ diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index eedf4181c..5612f458c 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -33,6 +33,9 @@ import { T } from "./T"; import { tx } from "@/platform/i18n"; import { useTreeReorder, type TreeReorder } from "./treeReorder"; import { useTreeDirectoryContext } from "./treeDirectoryContext"; +import { useIconRowHeight } from "./iconAppearance"; + +const DEFAULT_ROW_HEIGHT = 24; export type { TreeReorder } from "./treeReorder"; export interface TreeRowState { @@ -223,7 +226,7 @@ export function Tree(props: TreeProps) { onContextMenu, speedSearch = true, speedSearchMode = "highlight", - rowHeight = 24, + rowHeight: fixedRowHeight, indent = 20, className, rowClassName, @@ -299,6 +302,8 @@ export function Tree(props: TreeProps) { const indexOfKey = useMemo(() => new Map(rows.map((r, i) => [r.key, i])), [rows]); const leadIndex = lead != null ? (indexOfKey.get(lead) ?? -1) : -1; + const themedRowHeight = useIconRowHeight(); + const rowHeight = fixedRowHeight ?? themedRowHeight ?? DEFAULT_ROW_HEIGHT; const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollRef.current, @@ -306,6 +311,7 @@ export function Tree(props: TreeProps) { overscan: 12, getItemKey: (i) => rows[i]!.key, }); + useEffect(() => virtualizer.measure(), [virtualizer, rowHeight]); // Keep the keyboard lead in view when the lead changes — not when its index // shifts because expanding or collapsing elsewhere inserted rows above it: diff --git a/src/ui/iconAppearance.test.tsx b/src/ui/iconAppearance.test.tsx new file mode 100644 index 000000000..8a0cadf3c --- /dev/null +++ b/src/ui/iconAppearance.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { act, render } from "@testing-library/react"; +import { Tree } from "./Tree"; +import { setIconAppearance } from "./iconAppearance"; + +const root = () => document.documentElement; + +afterEach(() => setIconAppearance(null)); + +describe("icon appearance", () => { + it("applies monochrome, saturation, opacity, accent and tree icon size to the document", () => { + setIconAppearance({ monochrome: "#546E7A", saturation: 10, opacity: 60, accent: "#009688", iconSize: 20 }); + expect(root().style.getPropertyValue("--oi-icon-filter")).toBe("url(#oi-icon-mono) saturate(0.5)"); + expect(root().style.getPropertyValue("--oi-icon-opacity")).toBe("0.6"); + expect(root().style.getPropertyValue("--oi-icon-accent")).toBe("#009688"); + expect(root().style.getPropertyValue("--oi-tree-icon-size")).toBe("20px"); + expect(root().dataset.oiIconFilter).toBe(""); + expect(root().dataset.oiAccent).toBe(""); + expect(document.querySelector("#oi-icon-mono feColorMatrix")?.getAttribute("values")).toContain("0."); + + setIconAppearance(null); + expect(root().style.getPropertyValue("--oi-icon-filter")).toBe(""); + expect(root().dataset.oiIconFilter).toBeUndefined(); + expect(root().dataset.oiAccent).toBeUndefined(); + expect(root().dataset.oiTreeIconSize).toBeUndefined(); + }); + + it("uses the theme row height in trees that do not set their own", () => { + const roots = [{ key: "a", label: "a" }]; + const { container } = render( + n.key} + getLabel={(n) => n.label} + getChildren={() => undefined} + isExpandable={() => false} + aria-label="t" + />, + ); + const row = () => container.querySelector('[role="treeitem"]'); + expect(row()?.style.height).toBe("24px"); + act(() => setIconAppearance({ rowHeight: 30 })); + expect(row()?.style.height).toBe("30px"); + }); +}); diff --git a/src/ui/iconAppearance.ts b/src/ui/iconAppearance.ts new file mode 100644 index 000000000..2df83b162 --- /dev/null +++ b/src/ui/iconAppearance.ts @@ -0,0 +1,69 @@ +import { create } from "zustand"; +import type { FileIconThemeOptions } from "@/bindings"; + +const MONO_FILTER_ID = "oi-icon-mono"; +const MAX_SATURATION = 20; +const MONO_GAIN = 1.4; +const MONO_LIFT = 0.15; +const LUMA = [0.2126, 0.7152, 0.0722] as const; +const SVG_NS = "http://www.w3.org/2000/svg"; + +export const useIconRowHeight = create(() => null); + +function monoMatrix(hex: string): string { + const channel = (at: number) => parseInt(hex.slice(at, at + 2), 16) / 255; + const rows = [channel(1), channel(3), channel(5)].map((c) => + [...LUMA.map((l) => (c * MONO_GAIN * l).toFixed(4)), "0", (c * MONO_LIFT).toFixed(4)].join(" "), + ); + return [...rows, "0 0 0 1 0"].join(" "); +} + +function monoFilter(hex: string) { + let matrix = document.querySelector(`#${MONO_FILTER_ID} feColorMatrix`); + if (!matrix) { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("width", "0"); + svg.setAttribute("height", "0"); + svg.style.position = "absolute"; + const filter = document.createElementNS(SVG_NS, "filter"); + filter.id = MONO_FILTER_ID; + filter.setAttribute("color-interpolation-filters", "sRGB"); + matrix = document.createElementNS(SVG_NS, "feColorMatrix"); + matrix.setAttribute("type", "matrix"); + filter.append(matrix); + svg.append(filter); + document.body.append(svg); + } + matrix.setAttribute("values", monoMatrix(hex)); +} + +function setVar(name: string, flag: string, value: string | null) { + const root = document.documentElement; + if (value === null) { + root.style.removeProperty(name); + delete root.dataset[flag]; + } else { + root.style.setProperty(name, value); + root.dataset[flag] = ""; + } +} + +function iconFilter(o: FileIconThemeOptions | null): string | null { + const parts: string[] = []; + if (o?.monochrome) { + monoFilter(o.monochrome); + parts.push(`url(#${MONO_FILTER_ID})`); + } + if (o?.saturation) parts.push(`saturate(${1 - o.saturation / MAX_SATURATION})`); + return parts.length ? parts.join(" ") : null; +} + +export function setIconAppearance(o: FileIconThemeOptions | null): void { + useIconRowHeight.setState(o?.rowHeight ?? null, true); + if (typeof document === "undefined") return; + setVar("--oi-icon-filter", "oiIconFilter", iconFilter(o)); + setVar("--oi-icon-opacity", "oiIconOpacity", o?.opacity ? String(o.opacity / 100) : null); + setVar("--oi-icon-accent", "oiAccent", o?.accent ?? null); + setVar("--oi-tree-icon-size", "oiTreeIconSize", o?.iconSize ? `${o.iconSize}px` : null); +} diff --git a/src/ui/icons.module.css b/src/ui/icons.module.css index b3d2d0c7a..d85e4d045 100644 --- a/src/ui/icons.module.css +++ b/src/ui/icons.module.css @@ -31,3 +31,32 @@ line-height: 1; color: var(--fg-subtle); } + +:global(:root[data-oi-icon-filter]) .icon { + filter: var(--oi-icon-filter); +} + +:global(:root[data-oi-icon-opacity]) .icon { + opacity: var(--oi-icon-opacity); +} + +.icon [data-themed="true"][fill] { + fill: var(--oi-icon-accent, var(--accent)); +} + +.icon [data-themed="true"][stroke] { + stroke: var(--oi-icon-accent, var(--accent)); +} + +:global(:root[data-oi-accent]) .icon [data-tint="true"][fill] { + fill: var(--oi-icon-accent); +} + +:global(:root[data-oi-accent]) .icon [data-tint="true"][stroke] { + stroke: var(--oi-icon-accent); +} + +:global(:root[data-oi-tree-icon-size]) :global([role="tree"]) .icon { + width: var(--oi-tree-icon-size); + height: var(--oi-tree-icon-size); +} diff --git a/src/ui/icons.test.tsx b/src/ui/icons.test.tsx index 8ad1b2e0f..8179d2a41 100644 --- a/src/ui/icons.test.tsx +++ b/src/ui/icons.test.tsx @@ -4,7 +4,17 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { render } from "@testing-library/react"; import { activateFeatures } from "@/features"; import { useContributions } from "@/workbench/contributions"; -import { Icon, ICON_NAMES, SymbolBadge, fileIconName, hasIcon, iconMarkup, iconMarkupOrFallback } from "./icons"; +import { + Icon, + ICON_NAMES, + SymbolBadge, + fileIconName, + hasIcon, + iconMarkup, + iconMarkupOrFallback, + registerExternalIcons, + setFileIconTheme, +} from "./icons"; describe("tool window icons", () => { let dispose: () => void = () => {}; @@ -46,11 +56,35 @@ describe("Icon", () => { it("falls back like for unknown names, without React", () => { const { container } = render(); - expect(iconMarkupOrFallback("symbol-macro")).toBe(container.innerHTML); - const withoutIds = (html: string) => html.replace(/\bm\d+-/g, ""); + const withoutData = (html: string) => html.replace(/ data-icon(?:-[a-z]+)?="[^"]*"/g, ""); + expect(withoutData(iconMarkupOrFallback("symbol-macro"))).toBe(container.innerHTML); + const withoutIds = (html: string) => withoutData(html).replace(/\bm\d+-/g, ""); expect(withoutIds(iconMarkupOrFallback("no-such-icon"))).toBe(withoutIds(iconMarkup("file")!)); }); + it("keeps the requested name on markup so a theme change can repaint it", () => { + expect(iconMarkupOrFallback("no-such-icon", 14, "x")).toContain( + 'data-icon="no-such-icon" data-icon-size="14" data-icon-class="x"', + ); + expect(iconMarkupOrFallback("symbol-macro")).toContain('data-icon="symbol-macro"'); + }); + + it("repaints markup outside React when the icon theme changes", () => { + const unregister = registerExternalIcons([["ext:t/arrow", '']]); + document.body.innerHTML = `
${iconMarkupOrFallback("chevron-right", 14)}
`; + setFileIconTheme({ + file: () => null, + folder: () => null, + icon: (n) => (n === "chevron-right" ? "ext:t/arrow" : null), + }); + expect(document.body.innerHTML).toContain("M1 1h9z"); + expect(document.body.querySelector("[data-icon]")?.getAttribute("data-icon")).toBe("chevron-right"); + setFileIconTheme(null); + expect(document.body.innerHTML).not.toContain("M1 1h9z"); + unregister(); + document.body.innerHTML = ""; + }); + it("renders monochrome icons with currentColor and coloured ones with both theme variants", () => { const { container } = render( <> diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 1d124948d..ddd4f513e 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -539,11 +539,20 @@ const SYMBOL_ICONS: Partial> = { text: "fileTypes/text", }; +const NOT_SYMBOLS: ReadonlySet = new Set(["file", "text"]); + +export function isSymbolIcon(name: string): boolean { + if (name.startsWith("symbol-")) return true; + return Object.hasOwn(SYMBOL_ICONS, name) && !NOT_SYMBOLS.has(name); +} + export type IconName = BaseName | `symbol-${SymbolKind}`; export const ICON_NAMES = Object.keys(ICONS) as BaseName[]; function iconPath(name: string): string | undefined { + const themed = fileIconTheme?.icon?.(name); + if (themed && EXTERNAL.has(themed)) return themed; if (name.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.has(name) ? name : undefined; if (name.startsWith("symbol-")) return SYMBOL_ICONS[name.slice(7) as SymbolKind]; return (ICONS as Record)[name]; @@ -577,9 +586,10 @@ export function registerExternalIcons(icons: ReadonlyArray= 18)) || prepare(ICONS.file, size >= 18) || EMPTY; @@ -726,14 +742,37 @@ let markupSeq = 0; /** SVG markup for non-React (CodeMirror DOM) callers; `null` when the name is unknown. */ export function iconMarkup(name: string, size = 16, className?: string): string | null { + return markupAs(name, name, size, className); +} + +function markupAs(name: string, shown: string, size: number, className?: string): string | null { const path = iconPath(name); const p = path && prepare(path, size >= 18); if (!p) return null; const cls = clsx(styles.icon, p.mono && styles.mono, className); const html = withIds(p, `m${++markupSeq}-`); - return ``; + return ``; } +const attr = (v: string) => v.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`); + +function markupData(name: string, size: number, className?: string) { + const cls = className ? ` data-icon-class="${attr(className)}"` : ""; + return `data-icon="${attr(name)}" data-icon-size="${size}"${cls}`; +} + +function repaintMarkup() { + if (typeof document === "undefined") return; + for (const el of document.querySelectorAll("[data-icon][data-icon-size]")) { + const name = el.getAttribute("data-icon") ?? ""; + const size = Number(el.getAttribute("data-icon-size")) || 16; + const className = el.getAttribute("data-icon-class") ?? undefined; + el.outerHTML = iconMarkupOrFallback(name, size, className); + } +} + +useFileIconRevision.subscribe(repaintMarkup); + /** * Gutter icon markup in the 14px gutter column. The plain play glyph fills its canvas, so it is * drawn smaller; the other gutter icons (run-all, status badges, inheritance arrows, file types) @@ -748,15 +787,15 @@ export function gutterIconMarkup(name: string): string | null { * (a symbol letter badge, else the generic file icon). Plain strings, so * CodeMirror callers need no React renderer. */ -export function iconMarkupOrFallback(name: string, size = 16): string { - const html = iconMarkup(name, size); +export function iconMarkupOrFallback(name: string, size = 16, className?: string): string { + const html = iconMarkup(name, size, className); if (html !== null) return html; if (name.startsWith("symbol-")) { const kind = name.slice(7).replace(/[^\w-]/g, ""); const letter = symbolLetter(kind).replace(/[&<>]/g, (c) => `&#${c.charCodeAt(0)};`); - return ``; + return ``; } - return iconMarkup("file", size) ?? ""; + return markupAs("file", name, size, className) ?? ""; } export function hasIcon(name: string): boolean { @@ -1006,14 +1045,14 @@ export function addFileIconProvider(resolve: (fileName: string) => string | null } /** Picks a file-type icon from a file name. */ -export function fileIconName(fileName: string): IconName { +export function fileIconName(fileName: string, path: string | null = null): IconName { const custom = iconOverride?.(fileName); if (custom) return custom; for (const provide of fileIconProviders) { const icon = provide(fileName); if (icon && iconPath(icon)) return icon as IconName; } - const themed = fileIconTheme?.file(fileName); + const themed = fileIconTheme?.file(fileName, path); if (themed) return themed as IconName; const lower = fileName.toLowerCase(); const named = FILE_NAMES[lower] ?? NAME_PATTERNS.find(([re]) => re.test(lower))?.[1]; diff --git a/src/ui/patternIndex.test.ts b/src/ui/patternIndex.test.ts new file mode 100644 index 000000000..a0d9f0968 --- /dev/null +++ b/src/ui/patternIndex.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createPatternIndex, extensionKeys, firstCharKeys } from "./patternIndex"; + +describe("pattern index keys", () => { + it("derives the extensions a pattern can end with", () => { + expect(extensionKeys("\\.ya?ml$")?.sort()).toEqual(["yaml", "yml"]); + expect(extensionKeys(".*\\.(es|edql)$")?.sort()).toEqual(["edql", "es"]); + expect(extensionKeys("\\.[cm]?js$")?.sort()).toEqual(["cjs", "js", "mjs"]); + expect(extensionKeys("\\.(tar\\.gz|tgz)$")?.sort()).toEqual(["gz", "tgz"]); + expect(extensionKeys("^Cargo\\.toml$")).toEqual(["toml"]); + expect(extensionKeys("/(tests?)/.*\\.java$")).toEqual(["java"]); + expect(extensionKeys("\\.js")).toBeNull(); + expect(extensionKeys("\\.(md|[a-z]+)$")).toBeNull(); + expect(extensionKeys("a\\.md$|b\\.txt$")).toBeNull(); + expect(extensionKeys("(architecture)(\\.(md|txt))?$")).toBeNull(); + }); + + it("derives the characters an anchored pattern can start with", () => { + expect(firstCharKeys("^README")).toEqual(["R"]); + expect(firstCharKeys("^(README|readme)(\\.md)?$")?.sort()).toEqual(["R", "r"]); + expect(firstCharKeys("^[Mm]akefile$")?.sort()).toEqual(["M", "m"]); + expect(firstCharKeys("^\\.babelrc$")).toEqual(["."]); + expect(firstCharKeys("^\\.?kitchen")).toBeNull(); + expect(firstCharKeys("README")).toBeNull(); + expect(firstCharKeys("^a|b")).toBeNull(); + }); +}); + +describe("pattern index", () => { + const patterns = [ + "^README", + "\\.ya?ml$", + "(main|ci)\\.ya?ml$", + "\\.md$", + "^\\.github", + "/tests?/.*\\.java$", + "\\.java$", + "^(a|b)", + "x", + "^cargo\\.toml$", + "\\.TXT$", + ].map((source, order) => ({ + re: new RegExp(source, source.includes("cargo") || source.includes("TXT") ? "i" : ""), + source, + path: source.startsWith("/"), + order, + })); + + const linear = (name: string, path: string | null) => + patterns.find((p) => { + const subject = p.path ? path : name; + return subject !== null && p.re.test(subject); + })?.order; + + it("finds the same first match as a linear scan", () => { + const index = createPatternIndex(patterns); + const names = [ + "README.md", + "ci.yml", + "a.yaml", + "x.md", + ".github", + "Foo.java", + "b.txt", + "box", + "nothing.rs", + "md", + "Cargo.toml", + "NOTES.txt", + ]; + for (const name of names) { + for (const path of [null, `/p/tests/${name}`, `/p/src/${name}`]) { + expect(index.find(name, path)?.order, `${name} ${path}`).toBe(linear(name, path)); + } + } + }); +}); diff --git a/src/ui/patternIndex.ts b/src/ui/patternIndex.ts new file mode 100644 index 000000000..7d7012cc5 --- /dev/null +++ b/src/ui/patternIndex.ts @@ -0,0 +1,219 @@ +const MAX_STRINGS = 64; +const SPECIAL = new Set([".", "*", "+", "?", "{", "}", "(", ")", "[", "]", "|", "^", "$", "\\"]); +const ESCAPED_LITERALS = new Set([ + ".", + "-", + "_", + "/", + "+", + "(", + ")", + "[", + "]", + "{", + "}", + "$", + "^", + "|", + "?", + "*", + "\\", +]); + +type Strings = string[] | null; + +interface Cursor { + src: string; + at: number; +} + +function product(a: string[], b: string[]): Strings { + if (a.length * b.length > MAX_STRINGS) return null; + return a.flatMap((x) => b.map((y) => x + y)); +} + +function parseClass(c: Cursor): Strings { + const chars: string[] = []; + c.at++; + while (c.at < c.src.length && c.src[c.at] !== "]") { + const ch = c.src[c.at]!; + if (ch === "^" || ch === "[" || (ch === "-" && chars.length > 0 && c.src[c.at + 1] !== "]")) return null; + if (ch === "\\") { + const next = c.src[c.at + 1]; + if (!next || !ESCAPED_LITERALS.has(next)) return null; + chars.push(next); + c.at += 2; + continue; + } + chars.push(ch); + c.at++; + } + if (c.src[c.at] !== "]") return null; + c.at++; + return chars.length ? chars : null; +} + +function parseAtom(c: Cursor): Strings { + const ch = c.src[c.at]!; + if (ch === "(") { + c.at += c.src.startsWith("(?:", c.at) ? 3 : 1; + if (c.src[c.at] === "?") return null; + const inner = parseAlternation(c); + if (inner === null || c.src[c.at] !== ")") return null; + c.at++; + return inner; + } + if (ch === "[") return parseClass(c); + if (ch === "\\") { + const next = c.src[c.at + 1]; + if (!next || !ESCAPED_LITERALS.has(next)) return null; + c.at += 2; + return [next]; + } + if (SPECIAL.has(ch)) return null; + c.at++; + return [ch]; +} + +function parseConcat(c: Cursor): Strings { + let out: string[] = [""]; + while (c.at < c.src.length && c.src[c.at] !== "|" && c.src[c.at] !== ")") { + const atom = parseAtom(c); + if (atom === null) return null; + const quantifier = c.src[c.at]; + if (quantifier === "*" || quantifier === "+" || quantifier === "{") return null; + const options = quantifier === "?" ? [...atom, ""] : atom; + if (quantifier === "?") c.at++; + const next = product(out, options); + if (next === null) return null; + out = next; + } + return out; +} + +function parseAlternation(c: Cursor): Strings { + const out: string[] = []; + for (;;) { + const branch = parseConcat(c); + if (branch === null) return null; + out.push(...branch); + if (out.length > MAX_STRINGS) return null; + if (c.src[c.at] !== "|") return out; + c.at++; + } +} + +function finiteStrings(src: string): Strings { + const c = { src, at: 0 }; + const out = parseAlternation(c); + return out !== null && c.at === src.length ? out : null; +} + +interface TopLevel { + alternation: boolean; + lastDot: number; +} + +function scanTopLevel(src: string): TopLevel { + let depth = 0; + let inClass = false; + let lastDot = -1; + let alternation = false; + for (let i = 0; i < src.length; i++) { + const ch = src[i]; + if (ch === "\\") { + if (!inClass && depth === 0 && src[i + 1] === ".") lastDot = i; + i++; + } else if (inClass) { + if (ch === "]") inClass = false; + } else if (ch === "[") inClass = true; + else if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (ch === "|" && depth === 0) alternation = true; + } + return { alternation, lastDot }; +} + +export function extensionKeys(src: string): string[] | null { + if (!src.endsWith("$") || src.endsWith("\\$")) return null; + const { alternation, lastDot } = scanTopLevel(src); + if (alternation || lastDot < 0) return null; + const tail = finiteStrings(src.slice(lastDot + 2, -1)); + if (tail === null) return null; + return [...new Set(tail.map((s) => s.slice(s.lastIndexOf(".") + 1)))]; +} + +export function firstCharKeys(src: string): string[] | null { + if (!src.startsWith("^") || scanTopLevel(src).alternation || src.length < 2) return null; + const c = { src, at: 1 }; + const atom = parseAtom(c); + const quantifier = src[c.at]; + if (atom === null || quantifier === "?" || quantifier === "*" || quantifier === "+" || quantifier === "{") { + return null; + } + if (atom.some((s) => s === "")) return null; + return [...new Set(atom.map((s) => s[0]!))]; +} + +export interface IndexedPattern { + re: RegExp; + source: string; + path: boolean; + order: number; +} + +export interface PatternIndex { + find(name: string, path: string | null): T | undefined; +} + +function add(map: Map, key: string, item: T) { + const list = map.get(key); + if (list) list.push(item); + else map.set(key, [item]); +} + +const NONE: readonly never[] = []; + +export function createPatternIndex( + patterns: readonly T[], + accept: (pattern: T, name: string, path: string | null) => boolean = (p, name, path) => { + const subject = p.path ? path : name; + return subject !== null && p.re.test(subject); + }, +): PatternIndex { + const byExt = new Map(); + const byFirst = new Map(); + const generic: T[] = []; + for (const p of patterns) { + const ext = extensionKeys(p.source); + if (ext) { + for (const key of new Set(ext.map((k) => k.toLowerCase()))) add(byExt, key, p); + continue; + } + const first = p.path ? null : firstCharKeys(p.source); + if (first) for (const key of new Set(first.map((k) => k.toLowerCase()))) add(byFirst, key, p); + else generic.push(p); + } + return { + find(name, path) { + const dot = name.lastIndexOf("."); + const lists = [ + generic, + dot >= 0 ? (byExt.get(name.slice(dot + 1).toLowerCase()) ?? NONE) : NONE, + byFirst.get(name.charAt(0).toLowerCase()) ?? NONE, + ]; + const at = [0, 0, 0]; + for (;;) { + let pick = -1; + for (let l = 0; l < 3; l++) { + const item = lists[l]![at[l]!]; + if (item && (pick < 0 || item.order < lists[pick]![at[pick]!]!.order)) pick = l; + } + if (pick < 0) return undefined; + const item = lists[pick]![at[pick]!]!; + at[pick]!++; + if (accept(item, name, path)) return item; + } + }, + }; +}