From c87d2a6b559945aa49e49489e728ba86ea0d7072 Mon Sep 17 00:00:00 2001 From: Sergei Melnikov <74730932+pdasilem@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:22:38 +0400 Subject: [PATCH] feat(plugins): let icon themes match by pattern, replace built-in icons and describe their settings page (#1177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icon themes could only map files by exact name or extension and folders by name. Themes can now match by regular expression or path (patterns that could backtrack badly, lookaround, inline flags and named groups are refused at install), ship dark variants, replace built-in UI and symbol (PSI) icons, and offer packs (groups) and variants (choices). A theme describes its own options page in Settings › Plugins as sections of items naming options the IDE implements (hide file/folder/UI/PSI icons, hollow folders, accent, monochrome, saturation, opacity, icon size, row height); values are stored per theme in appearance.fileIconThemeOptions. Existing themes are unchanged. Closes #1096 Co-authored-by: pdasilem <74730932+pdasilem@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 1 + crates/ide-core/src/settings.rs | 50 ++++ crates/ide-host/Cargo.toml | 1 + .../ide-host/src/plugins/icon_extensions.rs | 207 +++++++++++++++ crates/ide-host/src/plugins/icon_patterns.rs | 105 ++++++++ crates/ide-host/src/plugins/icon_settings.rs | 185 +++++++++++++ crates/ide-host/src/plugins/icons.rs | 247 +++++++++++++++++- crates/ide-host/src/plugins/mod.rs | 3 + plugins/README.md | 78 ++++++ src/bindings.ts | 27 +- src/editor/core/controller.ts | 2 +- src/editor/lsp/Popups.tsx | 4 +- src/editor/lsp/navigation.ts | 2 +- src/features/agents/Composer.tsx | 2 +- src/features/agents/SessionEditor.tsx | 2 +- src/features/analysis/DuplicatesView.tsx | 8 +- src/features/analysis/InspectionView.tsx | 2 +- src/features/build/BuildView.tsx | 2 +- src/features/code-insight/ProblemsView.tsx | 2 +- src/features/code-insight/UsagesPopup.tsx | 2 +- src/features/code-insight/UsagesView.tsx | 2 +- .../__tests__/iconThemeResolver.test.ts | 144 ++++++++++ .../plugins/__tests__/iconThemes.test.ts | 105 ++++++++ .../plugins/__tests__/plugins.test.tsx | 40 ++- src/features/plugins/iconThemeResolver.ts | 167 ++++++++++++ src/features/plugins/iconThemes.ts | 117 +++++---- src/features/plugins/points.tsx | 10 +- src/features/plugins/store.ts | 17 +- src/features/project-tree/ProjectView.tsx | 11 +- .../project-tree/RefactoringPreview.tsx | 2 +- .../search/bookmarks/BookmarkPopups.tsx | 2 +- src/features/search/recent/RecentPopups.tsx | 2 +- .../settings/__tests__/behavior.test.ts | 41 +++ .../__tests__/iconThemeSettings.test.tsx | 147 +++++++++++ src/features/settings/iconThemePages.tsx | 60 +++++ src/features/settings/index.ts | 3 + src/features/settings/model/behaviorSchema.ts | 5 + .../settings/pages/Behavior.module.css | 18 ++ .../settings/pages/IconThemeSettings.tsx | 159 +++++++++++ src/features/vcs/changeOps.tsx | 2 +- src/features/vcs/conflictsUi.tsx | 2 +- src/features/vcs/patches.tsx | 2 +- src/features/vcs/shelf.tsx | 4 +- src/platform/filePresentation.ts | 2 +- src/platform/iconThemeOptions.ts | 58 ++++ src/platform/themes.ts | 52 ++++ src/ui/Tree.tsx | 8 +- src/ui/iconAppearance.test.tsx | 45 ++++ src/ui/iconAppearance.ts | 69 +++++ src/ui/icons.module.css | 29 ++ src/ui/icons.test.tsx | 40 ++- src/ui/icons.tsx | 61 ++++- src/ui/patternIndex.test.ts | 77 ++++++ src/ui/patternIndex.ts | 219 ++++++++++++++++ 55 files changed, 2549 insertions(+), 106 deletions(-) create mode 100644 crates/ide-host/src/plugins/icon_extensions.rs create mode 100644 crates/ide-host/src/plugins/icon_patterns.rs create mode 100644 crates/ide-host/src/plugins/icon_settings.rs create mode 100644 src/features/plugins/__tests__/iconThemeResolver.test.ts create mode 100644 src/features/plugins/__tests__/iconThemes.test.ts create mode 100644 src/features/plugins/iconThemeResolver.ts create mode 100644 src/features/settings/__tests__/iconThemeSettings.test.tsx create mode 100644 src/features/settings/iconThemePages.tsx create mode 100644 src/features/settings/pages/IconThemeSettings.tsx create mode 100644 src/platform/iconThemeOptions.ts create mode 100644 src/ui/iconAppearance.test.tsx create mode 100644 src/ui/iconAppearance.ts create mode 100644 src/ui/patternIndex.test.ts create mode 100644 src/ui/patternIndex.ts 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; + } + }, + }; +}