feat(plugins): let icon themes match by pattern, replace built-in icons and describe their settings page (#1177)

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 <[email protected]>
Цей коміт міститься в:
Sergei Melnikov authored and GitHub committed 2026-11-04 14:22:38 +08:00
1 джерело 4fbe41194f
коміт c87d2a6b55
55 files changed
+2549 -106

No files matched your search

Створено
+1
Переглянути файл
@@ -4272,6 +4272,7 @@ dependencies = [
"ide-workspace",
"ignore",
"parking_lot",
"regex-syntax",
"reqwest",
"rustix",
"serde",
+1
Переглянути файл
@@ -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"
+50
Переглянути файл
@@ -351,6 +351,7 @@ pub struct AppearanceSettings {
pub preferred_dark_theme: String,
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
pub file_icon_theme: String,
pub file_icon_theme_options: BTreeMap<String, FileIconThemeOptions>,
/// 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:<id>`. 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<String, bool>,
pub choices: BTreeMap<String, String>,
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<String>,
pub saturation: Option<u8>,
pub opacity: Option<u8>,
pub icon_size: Option<u8>,
pub row_height: Option<u8>,
pub accent: Option<String>,
}
#[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::<Settings>(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();
+1
Переглянути файл
@@ -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
+207
Переглянути файл
@@ -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<PathBuf>,
}
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<String>,
#[serde(default)]
open_icon: Option<String>,
}
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<String, String>,
}
#[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<ChoiceOption>,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct Extensions {
#[serde(default)]
pub(super) patterns: Vec<Pattern>,
#[serde(default)]
pub(super) overrides: BTreeMap<String, String>,
#[serde(default)]
pub(super) groups: Vec<Group>,
#[serde(default)]
pub(super) choices: Vec<Choice>,
#[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<Item = &'a String>, 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<String, String>,
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(())
}
+105
Переглянути файл
@@ -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<u64, &'static str> {
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"(?P<n>a)").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"));
}
}
+185
Переглянути файл
@@ -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<String>,
#[serde(default)]
hint: Option<String>,
#[serde(default)]
invert: bool,
#[serde(default)]
choice: Option<String>,
#[serde(default)]
columns: Option<u8>,
#[serde(default)]
default: Option<SettingDefault>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct SettingsSection {
title: String,
items: Vec<SettingItem>,
}
#[derive(Serialize)]
struct ChoiceSummary<'a> {
id: &'a str,
name: &'a str,
default: &'a str,
options: Vec<OptionSummary<'a>>,
}
#[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(&section.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 &section.items {
item.validate(ext)?;
}
}
Ok(())
}
pub(super) fn summary(sections: &[SettingsSection], ext: &Extensions) -> serde_json::Value {
let choices: Vec<ChoiceSummary> = 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,
})
}
+238 -9
Переглянути файл
@@ -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<String, PathBuf>,
icon_definitions: BTreeMap<String, IconDefinition>,
#[serde(default)]
file: Option<String>,
#[serde(default)]
@@ -53,11 +55,25 @@ struct ThemeFile {
#[serde(default)]
folder_expanded: Option<String>,
#[serde(default)]
folder_open: Option<String>,
#[serde(default)]
file_extensions: BTreeMap<String, String>,
#[serde(default)]
file_names: BTreeMap<String, String>,
#[serde(default)]
folder_names: BTreeMap<String, String>,
#[serde(default)]
patterns: Vec<Pattern>,
#[serde(default)]
overrides: BTreeMap<String, String>,
#[serde(default)]
groups: Vec<Group>,
#[serde(default)]
choices: Vec<Choice>,
#[serde(default)]
accent: bool,
#[serde(default)]
settings: Vec<SettingsSection>,
}
/// 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/<icon id>`).
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<UiDecl, Str
return Err(format!("icon theme {:?} has an empty label", d.id));
}
let src = pkg.read_text(&d.path)?;
let theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
let mut theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
let extensions = Extensions {
patterns: std::mem::take(&mut theme.patterns),
overrides: std::mem::take(&mut theme.overrides),
groups: std::mem::take(&mut theme.groups),
choices: std::mem::take(&mut theme.choices),
accent: theme.accent,
};
let at = |what: &str| format!("{} ({what})", d.path.display());
if theme.icon_definitions.len() > 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<UiDecl, Str
let check_ref = |icon: &str, what: &str| -> 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<UiDecl, Str
let mut assets = Vec::with_capacity(theme.icon_definitions.len() + 1);
let mut files = vec![d.path.clone()];
for (icon, path) in &theme.icon_definitions {
validate_item_id(icon, "icon id")?;
let clean = read_icon(pkg, path)?;
assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean));
files.push(path.clone());
for (icon, def) in &theme.icon_definitions {
let dark = def.dark().map(|path| (path, DARK_SUFFIX));
for (path, suffix) in std::iter::once((def.light(), "")).chain(dark) {
let clean = read_icon(pkg, path)?;
assets.push((format!("{}{icon}{suffix}", svg_prefix(&d.id)), clean));
files.push(path.clone());
}
}
let map = serde_json::json!({
"file": theme.file,
"folder": theme.folder,
"folderExpanded": theme.folder_expanded,
"folderOpen": theme.folder_open,
"fileExtensions": file_extensions,
"fileNames": file_names,
"folderNames": folder_names,
"patterns": extensions.patterns,
"overrides": extensions.overrides,
"groups": extensions.groups,
"choices": extensions.choices,
"accent": extensions.accent,
});
if !theme.settings.is_empty() {
assets.push((settings_asset(&d.id), icon_settings::summary(&theme.settings, &extensions).to_string()));
}
assets.push((map_asset(&d.id), map.to_string()));
let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() });
let mut out = UiDecl::new(d.id.clone(), d.label, json);
@@ -612,4 +661,184 @@ mod tests {
assert!(icon_theme(&pkg, decl("evil.json")).unwrap_err().contains("evil.svg"));
assert!(icon_theme(&pkg, decl("../x.json")).is_err());
}
const SVG: &str = r#"<svg viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>"#;
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<UiDecl, String> {
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<serde_json::Value> {
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}");
}
}
}
+3
Переглянути файл
@@ -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;
+78
Переглянути файл
@@ -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-<kind>`, `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/<id>.toml` files use the IDE's own language format; see any file in
+22 -5
Переглянути файл
@@ -1596,6 +1596,7 @@ export type AppearanceSettings = {
preferredDarkTheme?: string,
/** A plugin's file icon theme (`<plugin-id>/<theme-id>`); 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:<id>`. 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:<id>`. Widgets in neither list nor `hidden`
* (registered later) keep their default place.
*/
export type MainToolbarSettings = {
left?: string[],
right?: string[],
+1 -1
Переглянути файл
@@ -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 {
+2 -2
Переглянути файл
@@ -488,7 +488,7 @@ function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definiti
<Popup open anchor={popup.anchor} onClose={closePopup} aria-label="Definition" className={styles.defPopup}>
<div className={styles.defFrame} tabIndex={-1} data-autofocus onKeyDown={onKeyDown}>
<div className={styles.defHeader}>
<Icon name={fileIconName(basename(location.path))} size={14} />
<Icon name={fileIconName(basename(location.path), location.path)} size={14} />
{many ? (
<Select
variant="ghost"
@@ -579,7 +579,7 @@ function PreviewDialog({ popup }: { popup: Extract<LspPopup, { kind: "preview" }
<ul className={styles.previewList} aria-label={tx("Changes")}>
{summary.entries.map((e) => (
<li key={e.path} className={styles.previewRow}>
<Icon name={fileIconName(basename(e.path))} size={14} className={styles.itemIcon} />
<Icon name={fileIconName(basename(e.path), e.path)} size={14} className={styles.itemIcon} />
<span className={styles.previewName}>{basename(e.path)}</span>
<span className={styles.previewPath}>{relativePath(root, e.path)}</span>
<span className={styles.spacer} />
+1 -1
Переглянути файл
@@ -118,7 +118,7 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
label: name ?? basename(l.path),
detail: name ? `${basename(l.path)} · ${where}` : where,
preview,
icon: fileIconName(basename(l.path)),
icon: fileIconName(basename(l.path), l.path),
run: () => navigateTo(l),
};
});
+1 -1
Переглянути файл
@@ -344,7 +344,7 @@ export function Composer(props: ComposerProps) {
>
{item.kind === "file" ? (
<>
<Icon name={item.dir ? "folder" : fileIconName(basename(item.rel))} size={16} />
<Icon name={item.dir ? "folder" : fileIconName(basename(item.rel), item.rel)} size={16} />
<span className={styles.itemName}>{basename(item.rel)}</span>
<span className={styles.itemDetail}>{item.rel}</span>
</>
+1 -1
Переглянути файл
@@ -1434,7 +1434,7 @@ function ChangesPane({
onActivate={(c) => void openChangeDiff(task, c.path)}
renderNode={(c) => (
<span className={styles.change} title={c.relPath}>
<Icon name={fileIconName(basename(c.path))} size={16} />
<Icon name={fileIconName(basename(c.path), c.path)} size={16} />
<span className={styles.changeName} style={{ color: KIND_COLOR[c.kind] }}>
{basename(c.path)}
</span>
+6 -2
Переглянути файл
@@ -41,7 +41,7 @@ function FragmentPane({ fragment }: { fragment: Fragment }) {
onClick={() => openFragment(fragment)}
title={tx("Open in editor")}
>
<Icon name={fileIconName(basename(fragment.path))} size={16} className={styles.fileIcon} />
<Icon name={fileIconName(basename(fragment.path), fragment.path)} size={16} className={styles.fileIcon} />
<span className={styles.strong}>{basename(fragment.path)}</span>
<span className={styles.muted}>
{fragment.range.start.line + 1}–{fragment.range.end.line + 1}
@@ -111,7 +111,11 @@ export function DuplicatesView() {
</span>
) : (
<span className={styles.row}>
<Icon name={fileIconName(basename(n.fragment.path))} size={16} className={styles.fileIcon} />
<Icon
name={fileIconName(basename(n.fragment.path), n.fragment.path)}
size={16}
className={styles.fileIcon}
/>
<span>{basename(n.fragment.path)}</span>
<span className={styles.muted}>
:{n.fragment.range.start.line + 1}–{n.fragment.range.end.line + 1}
+1 -1
Переглянути файл
@@ -301,7 +301,7 @@ export function InspectionView() {
case "file":
return (
<span className={clsx(styles.row, off)}>
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
<Icon name={fileIconName(basename(n.path), n.path)} size={16} className={styles.fileIcon} />
<span className={state.selected ? styles.strong : undefined}>{basename(n.path)}</span>
<span className={styles.muted}>{relativeDir(root, n.path)}</span>
<span className={styles.count}>{n.count}</span>
+1 -1
Переглянути файл
@@ -135,7 +135,7 @@ function BuildTree() {
.join(", ");
return (
<span className={styles.row}>
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
<Icon name={fileIconName(basename(n.path), n.path)} size={16} className={styles.fileIcon} />
<span className={clsx(styles.strong, errors > 0 && styles.fileError, state.selected && styles.selected)}>
{basename(n.path)}
</span>
+1 -1
Переглянути файл
@@ -301,7 +301,7 @@ export function ProblemsView() {
);
return (
<span className={styles.row}>
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
<Icon name={fileIconName(basename(n.path), n.path)} size={16} className={styles.fileIcon} />
<span
className={clsx(styles.fileName, c.error > 0 && styles.fileError, state.selected && styles.selectedText)}
>
+1 -1
Переглянути файл
@@ -192,7 +192,7 @@ function UsagesPopupInner() {
}
renderItem={(u) => (
<div className={styles.row}>
<Icon name={fileIconName(basename(u.path))} size={14} className={styles.icon} />
<Icon name={fileIconName(basename(u.path), u.path)} size={14} className={styles.icon} />
<UsagePreview usage={u} />
<span className={styles.spacer} />
<span className={styles.where}>
+1 -1
Переглянути файл
@@ -230,7 +230,7 @@ export function UsagesView() {
const path = n.group.path;
return (
<span className={clsx(styles.row, isExcluded(n) && styles.excluded)}>
<Icon name={fileIconName(basename(path))} size={16} className={styles.icon} />
<Icon name={fileIconName(basename(path), path)} size={16} className={styles.icon} />
<span className={styles.file}>{basename(path)}</span>
<span className={styles.muted}>{relativeDir(root, path)}</span>
<span className={styles.count}>
+144
Переглянути файл
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { iconResolver, type IconThemeMap, type IconThemePattern } from "../iconThemeResolver";
const name = (icon: string) => `ext:p/t/${icon}`;
const HIDDEN = "ext:p/t/__hidden";
const SPEC: IconThemePattern = {
pattern: "^.*\\.spec\\.ts$",
icon: "test",
target: "file",
path: false,
ignoreCase: true,
group: null,
openIcon: null,
};
const MAP: IconThemeMap = {
file: "file",
folder: "folder",
folderOpen: "folder-open",
fileExtensions: { ts: "ts" },
fileNames: { "readme.md": "readme" },
folderNames: { docs: "folder-docs" },
patterns: [
SPEC,
{
pattern: "/fixtures/",
icon: "fixture",
target: "file",
path: true,
ignoreCase: false,
group: "extra",
openIcon: null,
},
{
pattern: "^src$",
icon: "folder-src",
target: "folder",
path: false,
ignoreCase: false,
group: null,
openIcon: "folder-src-open",
},
{
pattern: "^angular\\.json$",
icon: "angular",
target: "file",
path: false,
ignoreCase: false,
group: "angular",
openIcon: null,
},
],
overrides: { "chevron-right": "arrow", "symbol-class": "klass", folder: "ui-folder", method: "meth" },
groups: [
{ id: "extra", name: "Extra", enabled: true },
{ id: "angular", name: "Angular", enabled: false },
],
choices: [
{
id: "arrows",
name: "Arrows",
default: "plain",
options: [
{ id: "plain", name: "Default", overrides: {} },
{ id: "plus", name: "Plus", overrides: { "chevron-right": "plus" } },
],
},
],
accent: true,
};
const resolve = (options = {}) => iconResolver(MAP, name, () => true, { hidden: HIDDEN, options });
describe("icon theme resolver", () => {
it("matches patterns in order before names and extensions", () => {
const theme = resolve();
expect(theme.file("A.SPEC.ts")).toBe(name("test"));
expect(theme.file("a.ts")).toBe(name("ts"));
expect(theme.file("README.md")).toBe(name("readme"));
expect(theme.file("x.bin")).toBe(name("file"));
});
it("matches path patterns against the normalized path only when one is given", () => {
const theme = resolve();
expect(theme.file("a.json", "C:\\p\\fixtures\\a.json")).toBe(name("fixture"));
expect(theme.file("a.json", "/p/fixtures/a.json")).toBe(name("fixture"));
expect(theme.file("a.json")).toBe(name("file"));
expect(theme.file("a.json", "/p/src/a.json")).toBe(name("file"));
});
it("follows the groups' defaults and the user's choice of packs", () => {
expect(resolve().file("angular.json")).toBe(name("file"));
expect(resolve({ groups: { angular: true } }).file("angular.json")).toBe(name("angular"));
expect(resolve({ groups: { extra: false } }).file("a.json", "/p/fixtures/a.json")).toBe(name("file"));
});
it("uses the open variants for folders holding open files only with hollow folders", () => {
expect(resolve().folder("src", false, true)).toBe(name("folder-src"));
expect(resolve({ hollowFolders: true }).folder("src", false, true)).toBe(name("folder-src-open"));
expect(resolve({ hollowFolders: true }).folder("src", true, false)).toBe(name("folder-src"));
expect(resolve({ hollowFolders: true }).folder("lib", false, true)).toBe(name("folder-open"));
expect(resolve().folder("Docs", false)).toBe(name("folder-docs"));
expect(resolve().folder("lib", true)).toBe(name("folder"));
});
it("hides file or folder icons", () => {
expect(resolve({ hideFiles: true }).file("a.ts")).toBe(HIDDEN);
expect(resolve({ hideFiles: true }).folder("src", false)).toBe(name("folder-src"));
expect(resolve({ hideFolders: true }).folder("src", false)).toBe(HIDDEN);
});
it("replaces built-in icons, the chosen option first", () => {
expect(resolve().icon("chevron-right")).toBe(name("arrow"));
expect(resolve().icon("symbol-class")).toBe(name("klass"));
expect(resolve({ choices: { arrows: "plus" } }).icon("chevron-right")).toBe(name("plus"));
expect(resolve({ choices: { arrows: "gone" } }).icon("chevron-right")).toBe(name("arrow"));
expect(resolve().icon("terminal")).toBeNull();
});
it("drops the UI or the symbol icon replacements the user turned off", () => {
const noUi = resolve({ hideUiIcons: true, choices: { arrows: "plus" } });
expect(noUi.icon("chevron-right")).toBeNull();
expect(noUi.icon("folder")).toBeNull();
expect(noUi.icon("symbol-class")).toBe(name("klass"));
const noPsi = resolve({ hidePsiIcons: true });
expect(noPsi.icon("symbol-class")).toBeNull();
expect(noPsi.icon("method")).toBeNull();
expect(noUi.icon("method")).toBe(name("meth"));
expect(noPsi.icon("folder")).toBe(name("ui-folder"));
});
it("skips icons the theme does not have and patterns the browser cannot compile", () => {
const map: IconThemeMap = {
...MAP,
patterns: [
{ ...SPEC, pattern: "(" },
{ ...SPEC, icon: "missing" },
],
};
const theme = iconResolver(map, name, (n) => n !== name("missing"), { hidden: HIDDEN, options: {} });
expect(theme.file("a.spec.ts")).toBe(name("ts"));
});
});
+105
Переглянути файл
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it } from "vitest";
import { waitFor } from "@testing-library/react";
import { useProject } from "@/platform/project";
import { useContributedIconThemes } from "@/platform/themes";
import { fileIconName, iconMarkup, themedFolderIconName, useFileIconRevision } from "@/ui";
import { useIconRowHeight } from "@/ui/iconAppearance";
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
import { addIconTheme, selectIconTheme, trackIconTheme, useIconThemes } from "../iconThemes";
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>';
const MAP = {
file: "file",
folder: "folder",
fileExtensions: {},
fileNames: {},
folderNames: {},
patterns: [
{
pattern: "^.*\\.spec\\.ts$",
icon: "test",
target: "file",
path: false,
ignoreCase: false,
group: null,
openIcon: null,
},
],
overrides: { "chevron-right": "arrow" },
groups: [{ id: "extra", name: "Extra", enabled: false }],
choices: [],
accent: true,
};
const savedProject = useProject.getState();
let stop: (() => void) | null = null;
function setOptions(options: unknown) {
useProject.setState({
settings: { ...useProject.getState().settings, appearance: { fileIconThemeOptions: { "p/t": options } } } as never,
});
}
afterEach(() => {
stop?.();
stop = null;
useIconThemes.setState({ override: undefined, available: new Map() });
useContributedIconThemes.setState({ themes: [] });
useProject.setState(savedProject, true);
setPluginsBackend(null);
});
describe("plugin icon themes", () => {
it("applies the theme's patterns, overrides and the user's options", async () => {
const backend = createMemoryPluginsBackend([]);
setPluginsBackend({
...backend,
assets: async () => [
["iconTheme/t/map", JSON.stringify(MAP)],
...["file", "folder", "test", "arrow"].map((icon) => [`iconTheme/t/svg/${icon}`, SVG] as [string, string]),
],
});
addIconTheme({ pluginId: "p", id: "t", label: "T" });
stop = trackIconTheme();
await selectIconTheme("p/t");
await waitFor(() => expect(fileIconName("a.spec.ts")).toBe("ext:p/t/test"));
expect(fileIconName("a.rs")).toBe("ext:p/t/file");
expect(iconMarkup("chevron-right")).toContain("M0 0h16v16z");
const revision = useFileIconRevision.getState();
setOptions({});
useProject.setState({ settings: { ...useProject.getState().settings } as never });
expect(useFileIconRevision.getState()).toBe(revision);
setOptions({ hideFiles: true, rowHeight: 30 });
expect(fileIconName("a.spec.ts")).toBe("ext:p/t/__hidden");
expect(themedFolderIconName("src")).toBe("ext:p/t/folder");
expect(useIconRowHeight.getState()).toBe(30);
await selectIconTheme(null);
await waitFor(() => expect(fileIconName("a.spec.ts")).not.toMatch(/^ext:/));
expect(iconMarkup("chevron-right")).not.toContain("M0 0h16v16z");
expect(useIconRowHeight.getState()).toBeNull();
});
it("offers the theme's settings whether or not it is selected", async () => {
const SETTINGS = { groups: MAP.groups, choices: [], accent: true, settings: [{ title: "T", items: [] }] };
const requested: string[] = [];
setPluginsBackend({
...createMemoryPluginsBackend([]),
assets: async (plugin, prefix) => {
requested.push(prefix);
return plugin === "acme.icons" ? [["iconTheme/t/settings", JSON.stringify(SETTINGS)]] : [];
},
});
const remove = addIconTheme({ pluginId: "acme.icons", id: "t", label: "T", settingsPage: "plugins.acme-icons.t" });
addIconTheme({ pluginId: "plain", id: "t", label: "T" });
const of = (id: string) => useContributedIconThemes.getState().themes.find((t) => t.id === id)!;
expect(of("acme.icons/t").settingsPage).toBe("plugins.acme-icons.t");
expect(await of("acme.icons/t").details!()).toEqual(SETTINGS);
expect(await of("plain/t").details!()).toBeNull();
expect(requested).toEqual(["iconTheme/t/settings", "iconTheme/t/settings"]);
remove();
expect(useContributedIconThemes.getState().themes.some((t) => t.id === "acme.icons/t")).toBe(false);
});
});
+39 -1
Переглянути файл
@@ -7,6 +7,7 @@ import type { PluginInfo } from "@/bindings";
import { _setActiveEditor } from "@/editor/api";
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { useContributedIconThemes } from "@/platform/themes";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
@@ -19,7 +20,7 @@ import {
startPluginBridge,
syncProjection,
} from "../bridge";
import { iconResolver } from "../iconThemes";
import { iconResolver, selectedIconTheme, useIconThemes } from "../iconThemes";
import { markerIcon, placeMarkers } from "../lineMarkerGutter";
import { SecretsDetails } from "../secrets";
import { usePluginStatusItems } from "../runtimeUi";
@@ -28,6 +29,7 @@ import { defineBuiltinUiPoints } from "../points";
import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection";
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
import {
adoptIconTheme,
compareVersions,
installFromDisk,
refreshPlugins,
@@ -606,6 +608,42 @@ describe("marketplace", () => {
});
describe("icon themes", () => {
const iconThemeUi = (pluginId: string, id: string) => ({
pluginId,
point: "iconThemes",
key: id,
decl: { id, label: id.toUpperCase(), icons: 1 },
});
it("adopts the first icon theme of a plugin when none is selected", async () => {
useIconThemes.setState({ override: undefined });
usePlugins.setState({ ui: [iconThemeUi("q", "other"), iconThemeUi("p", "first"), iconThemeUi("p", "second")] });
await adoptIconTheme("p");
expect(selectedIconTheme()).toBe("p/first");
await adoptIconTheme("q");
expect(selectedIconTheme()).toBe("p/first");
useIconThemes.setState({ override: undefined });
});
it("names each icon theme's settings page after its plugin and theme", () => {
syncUiContributions([iconThemeUi("acme.icons", "atom.dark")], [plugin({ id: "acme.icons", name: "Acme" })]);
try {
expect(useContributedIconThemes.getState().themes.find((t) => t.id === "acme.icons/atom.dark")).toMatchObject({
label: "ATOM.DARK",
settingsPage: "plugins.acme-icons.atom-dark",
});
} finally {
clearUiContributions();
}
});
it("adopts nothing for plugins without icon themes", async () => {
useIconThemes.setState({ override: undefined });
usePlugins.setState({ ui: [iconThemeUi("q", "other")] });
await adoptIconTheme("p");
expect(selectedIconTheme()).toBeNull();
});
it("resolves file names, then the longest extension, then the default icon", () => {
const theme = iconResolver(
{
+167
Переглянути файл
@@ -0,0 +1,167 @@
import type { FileIconThemeOptions } from "@/bindings";
import { isSymbolIcon, type FileIconTheme } from "@/ui";
import { createPatternIndex, type IndexedPattern } from "@/ui/patternIndex";
export interface IconThemePattern {
pattern: string;
icon: string;
target: "file" | "folder";
path: boolean;
ignoreCase: boolean;
group: string | null;
openIcon: string | null;
}
export interface IconThemeGroup {
id: string;
name: string;
enabled: boolean;
}
export interface IconThemeChoiceOption {
id: string;
name: string;
overrides: Record<string, string>;
}
export interface IconThemeChoice {
id: string;
name: string;
default: string;
options: IconThemeChoiceOption[];
}
export interface IconThemeMap {
file: string | null;
folder?: string | null;
folderExpanded?: string | null;
folderOpen?: string | null;
fileExtensions: Record<string, string>;
fileNames: Record<string, string>;
folderNames?: Record<string, string>;
patterns?: IconThemePattern[];
overrides?: Record<string, string>;
groups?: IconThemeGroup[];
choices?: IconThemeChoice[];
accent?: boolean;
}
export interface ResolverSettings {
hidden: string;
options: FileIconThemeOptions;
}
export interface ThemeResolver extends FileIconTheme {
icon(name: string): string | null;
}
interface Compiled extends IndexedPattern {
icon: string;
openIcon: string | null;
}
const MAX_NAME_LEN = 255;
const MAX_PATH_LEN = 1024;
const MAX_CACHED = 5000;
const normalizePath = (path: string | null | undefined) => (path ? path.replace(/\\/g, "/") : null);
function compile(map: IconThemeMap, target: IconThemePattern["target"], options: FileIconThemeOptions) {
const enabled = (group: string | null) => {
if (!group) return true;
return options.groups?.[group] ?? map.groups?.find((g) => g.id === group)?.enabled ?? true;
};
const compiled: Compiled[] = [];
(map.patterns ?? []).forEach((p, order) => {
if (p.target !== target || !enabled(p.group)) return;
try {
const re = new RegExp(p.pattern, p.ignoreCase ? "i" : "");
compiled.push({ re, source: p.pattern, path: p.path, order, icon: p.icon, openIcon: p.openIcon });
} catch {
return;
}
});
return createPatternIndex(compiled, (p, name, path) => {
const subject = p.path ? path : name;
if (subject === null || subject.length > (p.path ? MAX_PATH_LEN : MAX_NAME_LEN)) return false;
return p.re.test(subject);
});
}
function chosenOverrides(map: IconThemeMap, options: FileIconThemeOptions): Record<string, string> {
return Object.assign(
{},
...(map.choices ?? []).map((c) => {
const picked = options.choices?.[c.id];
const option = c.options.find((o) => o.id === picked) ?? c.options.find((o) => o.id === c.default);
return option?.overrides ?? {};
}),
);
}
function cached(resolve: (fileName: string, path: string | null) => string | null) {
const cache = new Map<string, string | null>();
return (fileName: string, path: string | null) => {
const key = path ?? `\0${fileName}`;
const hit = cache.get(key);
if (hit !== undefined) return hit;
if (cache.size >= MAX_CACHED) cache.clear();
const icon = resolve(fileName, path);
cache.set(key, icon);
return icon;
};
}
export function iconResolver(
map: IconThemeMap,
iconName: (icon: string) => string,
has: (name: string) => boolean,
settings: ResolverSettings = { hidden: "", options: {} },
): ThemeResolver {
const { options, hidden } = settings;
const pick = (icon: string | undefined | null) => (icon && has(iconName(icon)) ? iconName(icon) : null);
const files = compile(map, "file", options);
const folders = compile(map, "folder", options);
const chosen = chosenOverrides(map, options);
const overrides = map.overrides ?? {};
const replaced = (name: string) => {
if (isSymbolIcon(name) ? options.hidePsiIcons : options.hideUiIcons) return null;
if (Object.hasOwn(chosen, name)) return chosen[name];
return Object.hasOwn(overrides, name) ? overrides[name] : null;
};
const fileIcon = cached((fileName, path) => {
const lower = fileName.toLowerCase();
const hit = files.find(fileName, path);
const matched = hit && pick(hit.icon);
if (matched) return matched;
const named = pick(map.fileNames[lower]);
if (named) return named;
let rest = lower;
for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) {
rest = rest.slice(dot + 1);
const byExt = pick(map.fileExtensions[rest]);
if (byExt) return byExt;
}
return pick(map.file);
});
return {
file(fileName, path) {
if (options.hideFiles && hidden) return hidden;
return fileIcon(fileName, normalizePath(path));
},
folder(folderName, expanded, holdsOpenFile = false, path = null) {
if (options.hideFolders && hidden) return hidden;
const open = !!options.hollowFolders && holdsOpenFile;
const hit = folders.find(folderName, normalizePath(path));
const matched = hit && ((open && pick(hit.openIcon)) || pick(hit.icon));
if (matched) return matched;
const named = pick(map.folderNames?.[folderName.toLowerCase()]);
if (named) return named;
if (open && pick(map.folderOpen)) return pick(map.folderOpen);
return (expanded && pick(map.folderExpanded)) || pick(map.folder);
},
icon(name) {
return pick(replaced(name));
},
};
}
+70 -47
Переглянути файл
@@ -7,10 +7,16 @@
*/
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { validateIconThemeOptions } from "@/platform/iconThemeOptions";
import { useProject } from "@/platform/project";
import { addContributedIconTheme } from "@/platform/themes";
import { registerExternalIcons, setFileIconTheme, type FileIconTheme } from "@/ui";
import type { FileIconThemeOptions } from "@/bindings";
import { addContributedIconTheme, type IconThemeDetails } from "@/platform/themes";
import { registerExternalIcons, setFileIconTheme } from "@/ui";
import { setIconAppearance } from "@/ui/iconAppearance";
import { pluginsBackend } from "./backend";
import { iconResolver, type IconThemeMap } from "./iconThemeResolver";
export { iconResolver } from "./iconThemeResolver";
export interface IconThemeRef {
pluginId: string;
@@ -18,15 +24,7 @@ export interface IconThemeRef {
label: string;
/** The plugin's name. */
origin?: string;
}
export interface IconThemeMap {
file: string | null;
folder?: string | null;
folderExpanded?: string | null;
fileExtensions: Record<string, string>;
fileNames: Record<string, string>;
folderNames?: Record<string, string>;
settingsPage?: string;
}
interface IconThemesState {
@@ -47,6 +45,25 @@ export const useIconThemes = create<IconThemesState>(() => ({
const fromSettings = (s: ReturnType<typeof useProject.getState>) =>
(s.settings?.appearance as { fileIconTheme?: unknown } | undefined)?.fileIconTheme;
const optionsFromSettings = (s: ReturnType<typeof useProject.getState>) =>
(s.settings?.appearance as { fileIconThemeOptions?: unknown } | undefined)?.fileIconThemeOptions;
type ProjectState = ReturnType<typeof useProject.getState>;
function optionsChanged(key: string, s: ProjectState, prev: ProjectState): boolean {
if (optionsFromSettings(s) === optionsFromSettings(prev)) return false;
return JSON.stringify(optionsOf(s, key)) !== JSON.stringify(optionsOf(prev, key));
}
function optionsOf(state: ProjectState, key: string): FileIconThemeOptions {
const own = (optionsFromSettings(state) as Record<string, unknown> | undefined)?.[key];
return validateIconThemeOptions(own);
}
function themeOptions(key: string): FileIconThemeOptions {
return optionsOf(useProject.getState(), key);
}
/** The selected icon theme (`<pluginId>/<themeId>`), or null for the built-in icons. */
export function selectedIconTheme(): string | null {
const { override } = useIconThemes.getState();
@@ -63,11 +80,24 @@ export function useSelectedIconTheme(): string | null {
export const iconThemeKey = (pluginId: string, id: string) => `${pluginId}/${id}`;
async function loadDetails(theme: IconThemeRef): Promise<IconThemeDetails | null> {
const id = `iconTheme/${theme.id}/settings`;
const assets = await pluginsBackend().assets(theme.pluginId, id);
const json = assets.find(([asset]) => asset === id)?.[1];
return json ? (JSON.parse(json) as IconThemeDetails) : null;
}
/** Called by the `iconThemes` projection: the theme can be selected while its plugin is live. */
export function addIconTheme(theme: IconThemeRef): () => void {
const key = iconThemeKey(theme.pluginId, theme.id);
useIconThemes.setState((s) => ({ available: new Map(s.available).set(key, theme) }));
const unlist = addContributedIconTheme({ id: key, label: theme.label, origin: theme.origin });
const unlist = addContributedIconTheme({
id: key,
label: theme.label,
origin: theme.origin,
settingsPage: theme.settingsPage,
details: () => loadDetails(theme),
});
return () => {
unlist();
useIconThemes.setState((s) => {
@@ -92,44 +122,32 @@ export async function selectIconTheme(key: string | null) {
}
}
/**
* The theme's resolver. Files: exact name, then the longest matching
* extension, then its default file icon. Folders: exact name, then its
* default (expanded) folder icon.
*/
export function iconResolver(
map: IconThemeMap,
iconName: (icon: string) => string,
has: (name: string) => boolean,
): FileIconTheme {
const pick = (icon: string | undefined | null) => (icon && has(iconName(icon)) ? iconName(icon) : null);
return {
file(fileName) {
const lower = fileName.toLowerCase();
const named = pick(map.fileNames[lower]);
if (named) return named;
let rest = lower;
for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) {
rest = rest.slice(dot + 1);
const byExt = pick(map.fileExtensions[rest]);
if (byExt) return byExt;
}
return pick(map.file);
},
folder(folderName, expanded) {
const named = pick(map.folderNames?.[folderName.toLowerCase()]);
if (named) return named;
return (expanded && pick(map.folderExpanded)) || pick(map.folder);
},
};
}
let undo: (() => void) | null = null;
let loading: string | null = null;
interface Loaded {
key: string;
map: IconThemeMap;
iconName: (icon: string) => string;
names: ReadonlySet<string>;
}
let loaded: Loaded | null = null;
const HIDDEN_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"></svg>';
function install(theme: Loaded) {
const options = themeOptions(theme.key);
const hidden = theme.iconName("__hidden");
setFileIconTheme(
iconResolver(theme.map, theme.iconName, (n) => theme.names.has(n) || n === hidden, { hidden, options }),
);
setIconAppearance(options);
}
async function apply(key: string | null) {
const theme = key ? useIconThemes.getState().available.get(key) : undefined;
if (!theme) {
if (!key || !theme) {
undo?.();
undo = null;
useIconThemes.setState({ applied: null });
@@ -151,10 +169,14 @@ async function apply(key: string | null) {
.map(([id, svg]) => [iconName(id.slice(svgPrefix.length)), svg] as const);
const names = new Set(icons.map(([n]) => n));
undo?.();
const unregister = registerExternalIcons(icons);
setFileIconTheme(iconResolver(map, iconName, (n) => names.has(n)));
const unregister = registerExternalIcons([...icons, [iconName("__hidden"), HIDDEN_SVG]]);
const current: Loaded = { key, map, iconName, names };
loaded = current;
install(current);
undo = () => {
loaded = null;
setFileIconTheme(null);
setIconAppearance(null);
unregister();
};
useIconThemes.setState({ applied: key });
@@ -179,6 +201,7 @@ export function trackIconTheme(): () => void {
// Settings are shared by every window: a choice made in one shows in all.
useProject.subscribe((s, prev) => {
if (fromSettings(s) !== fromSettings(prev)) sync();
else if (loaded && optionsChanged(loaded.key, s, prev)) install(loaded);
}),
];
return () => {
+8 -2
Переглянути файл
@@ -13,7 +13,7 @@ import { Button, Kbd, T } from "@/ui";
import { addIconTheme, iconThemeKey, selectIconTheme, useSelectedIconTheme } from "./iconThemes";
import { defineUiPoint, type UiItem } from "./projection";
import { SecretsDetails, type SecretDecl } from "./secrets";
import { defineViewUiPoints } from "./uiPoints";
import { defineViewUiPoints, pluginSettingsPageId } from "./uiPoints";
import { isLive, runPluginCommand } from "./store";
import styles from "./PluginsPage.module.css";
@@ -168,7 +168,13 @@ export function defineBuiltinUiPoints(): () => void {
defineUiPoint<IconThemeDecl>("iconThemes", {
title: "File Icon Themes",
project: ({ pluginId, decl }, { plugin }) =>
addIconTheme({ pluginId, id: decl.id, label: decl.label, origin: plugin.name }),
addIconTheme({
pluginId,
id: decl.id,
label: decl.label,
origin: plugin.name,
settingsPage: `${pluginSettingsPageId(pluginId)}.${decl.id.replace(/\./g, "-")}`,
}),
Details: IconThemesDetails,
}),
// Stored by the backend; listed here with a field per secret.
+15 -2
Переглянути файл
@@ -13,6 +13,7 @@ import type {
import { notifications } from "@/workbench/contributions";
import { pluginsBackend, type PluginsBackend } from "./backend";
import { editorContext } from "./editorContext";
import { iconThemeKey, selectedIconTheme, selectIconTheme } from "./iconThemes";
export const SETTINGS_PAGE_ID = "plugins";
@@ -132,10 +133,19 @@ async function withBusy(id: string, title: string, op: () => Promise<unknown>):
const nameOf = (id: string) => usePlugins.getState().plugins.find((p) => p.id === id)?.name ?? id;
export function setPluginEnabled(id: string, enabled: boolean): Promise<boolean> {
return withBusy(id, `Could not ${enabled ? "enable" : "disable"} ${nameOf(id)}`, () =>
export async function adoptIconTheme(pluginId: string): Promise<void> {
if (selectedIconTheme()) return;
const first = usePlugins.getState().ui.find((c) => c.pluginId === pluginId && c.point === "iconThemes");
const id = (first?.decl as { id?: unknown } | undefined)?.id;
if (typeof id === "string") await selectIconTheme(iconThemeKey(pluginId, id));
}
export async function setPluginEnabled(id: string, enabled: boolean): Promise<boolean> {
const done = await withBusy(id, `Could not ${enabled ? "enable" : "disable"} ${nameOf(id)}`, () =>
pluginsBackend().setEnabled(id, enabled),
);
if (done && enabled) await adoptIconTheme(id);
return done;
}
export function reloadPlugin(id: string): Promise<boolean> {
@@ -287,6 +297,7 @@ export async function installFromDisk(path?: string, packageFile = false): Promi
}
if (!dir) return null;
usePlugins.setState({ installing: true });
let installed: string | null = null;
try {
const preview = await backend.previewInstall(dir);
const consent = await askConsent({
@@ -300,6 +311,7 @@ export async function installFromDisk(path?: string, packageFile = false): Promi
});
if (!consent) return null;
const info = await backend.installDir(dir, consent);
installed = info.status === "failed" ? null : info.id;
notifications().notify({
group: "plugins",
level: info.status === "failed" ? "warning" : "info",
@@ -313,6 +325,7 @@ export async function installFromDisk(path?: string, packageFile = false): Promi
} finally {
usePlugins.setState({ installing: false });
await refreshPlugins();
if (installed) await adoptIconTheme(installed);
}
}
+8 -3
Переглянути файл
@@ -128,6 +128,7 @@ function iconFor(
build: BuildPresentation,
detected: readonly RootFolder[],
expanded: boolean,
holdsOpen: boolean,
): string {
if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file";
if (node.icon) return node.icon;
@@ -139,9 +140,9 @@ function iconFor(
const key = rel !== null && build.modules.has(rel) ? rel : node.path;
const module = root ? moduleFolderIcon(key, node.root, build.modules) : null;
if (module) return module;
return node.root ? "module" : (themedFolderIconName(node.label, expanded) ?? "folder");
return node.root ? "module" : (themedFolderIconName(node.label, expanded, holdsOpen, node.path) ?? "folder");
}
return fileIconName(node.label);
return fileIconName(node.label, node.path);
}
// ---------------------------------------------------------------------------
@@ -258,6 +259,9 @@ interface RowProps {
onPointerDown: (node: ProjectNode, e: ReactPointerEvent) => void;
}
const holdsOpenFile = (dir: string, files: readonly string[]) =>
files.some((f) => f.length > dir.length && f.startsWith(dir) && (f[dir.length] === "/" || f[dir.length] === "\\"));
const Row = memo(function Row({
node,
matches,
@@ -279,9 +283,10 @@ const Row = memo(function Row({
const build = useProjectPresentation();
const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs);
const expanded = useProjectTree((s) => node.dir && s.expanded.has(node.key));
const holdsOpen = useProjectTree((s) => node.dir && holdsOpenFile(node.path, s.openFiles));
const icon =
node.dir || node.placeholder || node.icon
? iconFor(node, roots, root, build, detected, expanded)
? iconFor(node, roots, root, build, detected, expanded, holdsOpen)
: presentationIcon(node.path, file, roots, root, build.spring);
const moduleFolder =
node.dir && !node.placeholder && !!root && isModuleFolder(relativePath(root, node.path), node.root, build.modules);
+1 -1
Переглянути файл
@@ -81,7 +81,7 @@ function FileGroup({
<li className={styles.previewFile}>
<div className={styles.previewFileRow}>
<Checkbox checked={included} onChange={onToggle} aria-label={tx`Include ${nameOf(file.path)}`} />
<Icon name={fileIconName(nameOf(file.path))} size={14} />
<Icon name={fileIconName(nameOf(file.path), file.path)} size={14} />
<span className={styles.previewName}>{nameOf(file.path)}</span>
<span className={styles.previewPath}>{relativeTo(root, file.path) || file.path}</span>
<span className={styles.spacer} />
+1 -1
Переглянути файл
@@ -225,7 +225,7 @@ function BookmarksPopup() {
const fileLabel = (path: string, dir: boolean) => (
<span className={styles.fileRow}>
<Icon name={dir ? "folder" : fileIconName(basename(path))} size={14} />
<Icon name={dir ? "folder" : fileIconName(basename(path), path)} size={14} />
<span className={styles.fileName}>{basename(path)}</span>
<span className={styles.dim}>{root ? relPath(path) : path}</span>
</span>
+1 -1
Переглянути файл
@@ -633,7 +633,7 @@ function RecentLocationsDialog() {
renderItem={(l) => (
<div className={styles.location}>
<div className={styles.locationHeader}>
<Icon name={fileIconName(basename(l.rel))} size={14} className={styles.icon} />
<Icon name={fileIconName(basename(l.rel), l.rel)} size={14} className={styles.icon} />
<HighlightedText
className={styles.locationName}
text={basename(l.rel)}
+41
Переглянути файл
@@ -22,6 +22,47 @@ describe("appearance & behavior settings", () => {
expect(validateAppearanceExtras({ theme: "system" }).syncWithOs).toBe(true);
});
it("keeps valid file icon theme options and drops or clamps the rest", () => {
expect(validateAppearanceExtras({}).fileIconThemeOptions).toEqual({});
const options = validateAppearanceExtras({
fileIconThemeOptions: {
"p/t": {
groups: { tests: true, bad: "yes" },
choices: { arrows: "plus", bad: 1 },
hideFiles: true,
hollowFolders: "x",
hideUiIcons: true,
hidePsiIcons: 1,
monochrome: "#546E7A",
saturation: 50,
opacity: 1,
iconSize: 20.4,
rowHeight: 100,
accent: "red",
},
"not a key": { hideFiles: true },
"p/u": 5,
},
}).fileIconThemeOptions;
expect(options).toEqual({
"p/t": {
groups: { tests: true },
choices: { arrows: "plus" },
hideFiles: true,
hideFolders: false,
hollowFolders: false,
hideUiIcons: true,
hidePsiIcons: false,
monochrome: "#546E7A",
saturation: 20,
opacity: 10,
iconSize: 20,
rowHeight: 48,
accent: null,
},
});
});
it("defaults Show tool window badges to on and honours the stored value", () => {
expect(validateAppearanceExtras({}).showToolWindowBadges).toBe(true);
expect(validateAppearanceExtras({ showToolWindowBadges: false }).showToolWindowBadges).toBe(false);
+147
Переглянути файл
@@ -0,0 +1,147 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
addContributedIconTheme,
useContributedIconThemes,
type IconThemeChoice,
type IconThemeDetails,
} from "@/platform/themes";
import { useSettingsPages } from "@/workbench/contributions";
import { trackIconThemePages } from "../iconThemePages";
import { validateSettings } from "../model/schema";
import { useSettingsDialog } from "../model/staging";
import { BEHAVIOR_PAGES } from "../pages/behavior";
const savedDialog = useSettingsDialog.getState();
const KEY = "global|appearance.fileIconThemeOptions";
const PAGE = "plugins.p.t";
const staged = () => useSettingsDialog.getState().staged[KEY]?.value as Record<string, Record<string, unknown>>;
const DETAILS: IconThemeDetails = {
groups: [{ id: "tests", name: "Tests", enabled: false }],
choices: [
{
id: "arrows",
name: "Arrows style",
default: "plain",
options: [
{ id: "plain", name: "Default" },
{ id: "plus", name: "Plus-Minus" },
],
},
],
accent: true,
settings: [
{
title: "Toggles",
items: [
{ option: "hideFiles", label: "Files on", hint: "Shows file icons", invert: true },
{ option: "hidePsiIcons", label: "Hide symbols" },
],
},
{ title: "Packs", items: [{ option: "groups", columns: 2 }] },
{
title: "Looks",
items: [
{ option: "choice", choice: "arrows" },
{ option: "rowHeight", label: "Line height", default: 30 },
{ option: "accent", label: "Accent", default: "#112233" },
],
},
],
};
const theme = (details: IconThemeDetails | null): IconThemeChoice => ({
id: "p/t",
label: "Atom",
settingsPage: PAGE,
details: async () => details,
});
function select(selected: string, options: unknown = {}) {
const base = validateSettings({ appearance: { fileIconTheme: selected, fileIconThemeOptions: options } });
useSettingsDialog.setState({ base, globalBase: base, staged: {} });
}
const page = () => useSettingsPages.getState().pages.get(PAGE);
async function openPage() {
await waitFor(() => expect(page()).toBeDefined());
const Page = page()!.component!;
render(<Page />);
}
let stop: () => void = () => {};
beforeEach(() => {
stop = trackIconThemePages();
});
afterEach(() => {
stop();
useSettingsDialog.setState(savedDialog);
useContributedIconThemes.setState({ themes: [] });
});
describe("icon theme settings page", () => {
it("is registered from the theme's settings while the theme is contributed", async () => {
const remove = addContributedIconTheme(theme(DETAILS));
await waitFor(() => expect(page()?.title).toBe("Atom"));
expect(page()?.keywords).toEqual(expect.arrayContaining(["Toggles", "Files on", "Line height", "Tests"]));
act(remove);
expect(page()).toBeUndefined();
});
it("is not registered for themes without settings", async () => {
addContributedIconTheme(theme(null));
addContributedIconTheme({ ...theme({ ...DETAILS, settings: [] }), id: "p/u", settingsPage: "plugins.p.u" });
await new Promise((r) => setTimeout(r, 0));
expect(page()).toBeUndefined();
expect(useSettingsPages.getState().pages.has("plugins.p.u")).toBe(false);
});
it("renders the sections and items the theme declares", async () => {
addContributedIconTheme(theme(DETAILS));
select("");
await openPage();
for (const title of ["Toggles", "Packs", "Looks"]) {
expect(screen.getByRole("region", { name: title })).toBeInTheDocument();
}
expect(screen.getByText("Shows file icons")).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /saturation/i })).toBeNull();
expect(screen.getByRole("checkbox", { name: "Files on" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Hide symbols" })).not.toBeChecked();
});
it("stages the options with the theme's defaults, keeping other themes' options", async () => {
addContributedIconTheme(theme(DETAILS));
select("", { "q/u": { hideFiles: true } });
await openPage();
fireEvent.click(screen.getByRole("checkbox", { name: "Files on" }));
expect(staged()["p/t"]).toMatchObject({ hideFiles: true });
fireEvent.click(screen.getByRole("checkbox", { name: "Hide symbols" }));
expect(staged()["p/t"]).toMatchObject({ hidePsiIcons: true });
fireEvent.click(screen.getByRole("checkbox", { name: "Tests" }));
expect(staged()["p/t"]).toMatchObject({ groups: { tests: true } });
fireEvent.click(screen.getByRole("button", { name: "Arrows style" }));
fireEvent.click(await screen.findByRole("option", { name: "Plus-Minus" }));
expect(staged()["p/t"]).toMatchObject({ choices: { arrows: "plus" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Line height" }));
expect(staged()["p/t"]).toMatchObject({ rowHeight: 30 });
fireEvent.click(screen.getByRole("checkbox", { name: "Accent" }));
expect(staged()["p/t"]).toMatchObject({ accent: "#112233" });
expect(staged()["q/u"]).toMatchObject({ hideFiles: true });
});
it("is no longer part of Settings › Appearance", () => {
addContributedIconTheme(theme(DETAILS));
select("p/t");
const option = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!
.sections!.flatMap((s) => s.options)
.find((o) => o.kind === "custom" && o.label === "File icon theme")!;
if (option.kind !== "custom") throw new Error("File icon theme row missing");
const Row = option.component;
render(<Row />);
expect(screen.queryByRole("checkbox", { name: "Files on" })).toBeNull();
});
});
+60
Переглянути файл
@@ -0,0 +1,60 @@
import { useContributedIconThemes, type IconThemeChoice, type IconThemeDetails } from "@/platform/themes";
import { registerSettingsPage } from "@/workbench/contributions";
import { IconThemeSettings } from "./pages/IconThemeSettings";
function keywords(theme: IconThemeChoice, details: IconThemeDetails): string[] {
const words = details.settings.flatMap((s) => [
s.title,
...s.items.flatMap((item) => {
if (item.option === "groups") return details.groups.map((g) => g.name);
if (item.option === "choice") return details.choices.filter((c) => c.id === item.choice).map((c) => c.name);
return [item.label ?? ""];
}),
]);
return [theme.origin ?? "", ...words].filter(Boolean);
}
function register(theme: IconThemeChoice, page: string, details: IconThemeDetails): () => void {
return registerSettingsPage({
id: page,
title: theme.label,
keywords: keywords(theme, details),
component: () => <IconThemeSettings themeKey={theme.id} details={details} />,
});
}
export function trackIconThemePages(): () => void {
const pages = new Map<IconThemeChoice, () => void>();
const pending = new Set<IconThemeChoice>();
const add = async (theme: IconThemeChoice, page: string) => {
pending.add(theme);
try {
const details = await theme.details?.();
if (!pending.has(theme) || !details?.settings.length) return;
pages.set(theme, register(theme, page, details));
} catch (e) {
console.warn(`icon theme ${theme.id}: cannot load its settings`, e);
} finally {
pending.delete(theme);
}
};
const sync = (themes: readonly IconThemeChoice[]) => {
for (const theme of pending) if (!themes.includes(theme)) pending.delete(theme);
for (const [theme, undo] of pages) {
if (themes.includes(theme)) continue;
undo();
pages.delete(theme);
}
for (const theme of themes) {
if (theme.settingsPage && !pages.has(theme) && !pending.has(theme)) void add(theme, theme.settingsPage);
}
};
sync(useContributedIconThemes.getState().themes);
const unsubscribe = useContributedIconThemes.subscribe((s) => sync(s.themes));
return () => {
unsubscribe();
pending.clear();
pages.forEach((undo) => undo());
pages.clear();
};
}
+3
Переглянути файл
@@ -40,6 +40,7 @@ import { settingsSearchEntries } from "./searchIndex";
import { activatePythonStatus } from "./languages/PythonStatus";
import { nativeToAsciiAction } from "./nativeToAscii";
import { isImeKey } from "@/platform/ime";
import { trackIconThemePages } from "./iconThemePages";
/** Settings-driven runtime state (zoom, fonts, theme) of a secondary window. */
export const activateRuntimeSettings = trackRuntimeSettings;
@@ -106,6 +107,7 @@ async function showSettings(page?: string, newProjects = false) {
export function activate(): () => void {
const disposers = [
trackRuntimeSettings(),
trackIconThemePages(),
activatePythonStatus(),
unloadBuiltinPages,
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
@@ -194,6 +196,7 @@ function installWindowKeys(): () => void {
export function activateSettingsWindow(parent: string): () => void {
const disposers = [
trackRuntimeSettings(),
trackIconThemePages(),
unloadBuiltinPages,
registerActions(settingsActions((page, newProjects) => relaunch({ page, newProjects: !!newProjects }))),
installActionProxies(parent),
+5
Переглянути файл
@@ -5,7 +5,9 @@
*/
import { isThemeChoiceId, type ThemeChoiceId } from "@/platform/themes";
import type { FileIconThemeOptions } from "@/bindings";
import { ZOOM_LIMITS } from "@/platform/zoom";
import { validateIconThemeOptionsMap } from "@/platform/iconThemeOptions";
export type Antialiasing = "subpixel" | "greyscale" | "none";
export type OpenProjectIn = "ask" | "new" | "same" | "attach";
@@ -19,6 +21,7 @@ export interface AppearanceExtras {
preferredDarkTheme: ConcreteTheme;
/** A plugin's file icon theme (`<pluginId>/<themeId>`); empty for the built-in icons. */
fileIconTheme: string;
fileIconThemeOptions: Record<string, FileIconThemeOptions>;
zoom: number;
useCustomFont: boolean;
uiFontFamily: string;
@@ -73,6 +76,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
preferredLightTheme: "light",
preferredDarkTheme: "dark",
fileIconTheme: "",
fileIconThemeOptions: {},
zoom: 100,
useCustomFont: false,
uiFontFamily: "Inter",
@@ -127,6 +131,7 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
preferredDarkTheme: concrete(a.preferredDarkTheme, d.preferredDarkTheme),
fileIconTheme:
typeof a.fileIconTheme === "string" && /^[^/\s]+\/[^/\s]+$/.test(a.fileIconTheme) ? a.fileIconTheme : "",
fileIconThemeOptions: validateIconThemeOptionsMap(a.fileIconThemeOptions),
zoom: int(a.zoom, d.zoom, ZOOM_LIMITS),
useCustomFont: b("useCustomFont"),
uiFontFamily: typeof a.uiFontFamily === "string" && a.uiFontFamily.trim() ? a.uiFontFamily.trim() : d.uiFontFamily,
+18
Переглянути файл
@@ -115,3 +115,21 @@
.center {
text-align: center;
}
.iconPacks {
display: grid;
grid-template-columns: repeat(var(--icon-pack-columns, 1), minmax(0, 1fr));
gap: 6px 24px;
padding: 2px 0;
}
.iconLabel {
padding: 2px 0;
}
.iconHint {
padding: 0 0 2px 20px;
color: var(--fg-subtle);
font-size: var(--fs-small);
line-height: 1.4;
}
+159
Переглянути файл
@@ -0,0 +1,159 @@
import type { CSSProperties, ReactNode } from "react";
import type { FileIconThemeOptions as Options } from "@/bindings";
import { Checkbox, Select } from "@/ui";
import { ICON_THEME_LIMITS, validateIconThemeOptions } from "@/platform/iconThemeOptions";
import type { IconThemeDetails, IconThemeSettingItem } from "@/platform/themes";
import { ColorSwatches, NumberInput, Row, Section, Toggle } from "../components/Form";
import { useSetting } from "../components/context";
import styles from "./Behavior.module.css";
type Flag = "hideFiles" | "hideFolders" | "hideUiIcons" | "hidePsiIcons" | "hollowFolders";
type Colour = "accent" | "monochrome";
type Amount = keyof typeof ICON_THEME_LIMITS;
const UNITS: Partial<Record<Amount, string>> = { opacity: "%", iconSize: "px", rowHeight: "px" };
interface ItemProps {
item: IconThemeSettingItem;
details: IconThemeDetails;
options: Options;
set: (patch: Partial<Options>) => void;
}
function Hint({ text }: { text?: string | null }) {
return text ? <div className={styles.iconHint}>{text}</div> : null;
}
function Line({ children }: { children: ReactNode }) {
return <div className={styles.fontRow}>{children}</div>;
}
function FlagItem({ item, options, set, flag }: ItemProps & { flag: Flag }) {
const value = !!options[flag];
return (
<Toggle
label={item.label ?? ""}
hint={item.hint ? <span>{item.hint}</span> : undefined}
checked={item.invert ? !value : value}
onChange={(on) => set({ [flag]: item.invert ? !on : on })}
/>
);
}
function GroupsItem({ item, details, options, set }: ItemProps) {
const style = { "--icon-pack-columns": item.columns ?? 1 } as CSSProperties;
return (
<>
{item.label && <div className={styles.iconLabel}>{item.label}</div>}
<div className={styles.iconPacks} style={style}>
{details.groups.map((g) => (
<Checkbox
key={g.id}
label={g.name}
checked={options.groups?.[g.id] ?? g.enabled}
onChange={(on) => set({ groups: { ...options.groups, [g.id]: on } })}
/>
))}
</div>
<Hint text={item.hint} />
</>
);
}
function ChoiceItem({ item, details, options, set }: ItemProps) {
const choice = details.choices.find((c) => c.id === item.choice);
if (!choice) return null;
return (
<Row label={item.label ?? choice.name} hint={item.hint ? <span>{item.hint}</span> : undefined}>
<Select
aria-label={item.label ?? choice.name}
value={options.choices?.[choice.id] ?? choice.default}
options={choice.options.map((o) => ({ value: o.id, label: o.name }))}
onChange={(value) => set({ choices: { ...options.choices, [choice.id]: value } })}
/>
</Row>
);
}
function ColourItem({ item, options, set, colour }: ItemProps & { colour: Colour }) {
const label = item.label ?? "";
const value = options[colour];
const fallback = String(item.default ?? "");
return (
<>
<Line>
<Checkbox label={label} checked={!!value} onChange={(on) => set({ [colour]: on ? fallback : null })} />
{value && (
<ColorSwatches
value={value}
presets={[{ value: fallback, label: "Default" }]}
onChange={(v) => set({ [colour]: v })}
aria-label={label}
/>
)}
</Line>
<Hint text={item.hint} />
</>
);
}
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 (
<>
<Line>
<Checkbox label={label} checked={value != null} onChange={(on) => set({ [amount]: on ? fallback : null })} />
{value != null && (
<NumberInput
aria-label={label}
value={value}
{...ICON_THEME_LIMITS[amount]}
integer
unit={UNITS[amount]}
onChange={(v) => set({ [amount]: v })}
/>
)}
</Line>
<Hint text={item.hint} />
</>
);
}
function Item(props: ItemProps) {
const { option } = props.item;
switch (option) {
case "hideFiles":
case "hideFolders":
case "hideUiIcons":
case "hidePsiIcons":
case "hollowFolders":
return <FlagItem {...props} flag={option} />;
case "groups":
return <GroupsItem {...props} />;
case "choice":
return <ChoiceItem {...props} />;
case "accent":
case "monochrome":
return <ColourItem {...props} colour={option} />;
case "saturation":
case "opacity":
case "iconSize":
case "rowHeight":
return <AmountItem {...props} amount={option} />;
}
}
export function IconThemeSettings({ themeKey, details }: { themeKey: string; details: IconThemeDetails }) {
const [all, setAll] = useSetting<Record<string, Options> | undefined>("appearance.fileIconThemeOptions");
const options = validateIconThemeOptions(all?.[themeKey]);
const set = (patch: Partial<Options>) => setAll({ ...all, [themeKey]: { ...options, ...patch } });
return details.settings.map((section, i) => (
<Section key={i} title={section.title}>
{section.items.map((item, j) => (
<Item key={j} item={item} details={details} options={options} set={set} />
))}
</Section>
));
}
+1 -1
Переглянути файл
@@ -260,7 +260,7 @@ function RollbackDialog({ files, onClose }: { files: FileStatus[]; onClose: () =
})
}
/>
<Icon name={fileIconName(baseName(f.path))} size={14} />
<Icon name={fileIconName(baseName(f.path), f.path)} size={14} />
<span className={styles.fileName} style={{ color: KIND_COLOR[kind] }}>
{baseName(f.path)}
</span>
+1 -1
Переглянути файл
@@ -126,7 +126,7 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) {
}}
renderItem={(f) => (
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }}>
<Icon name={fileIconName(baseName(f.path))} size={14} />
<Icon name={fileIconName(baseName(f.path), f.path)} size={14} />
<span className={styles.fileName} style={{ color: "var(--vcs-conflict)" }}>
{baseName(f.path)}
</span>
+1 -1
Переглянути файл
@@ -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")
}
>
<Icon name={fileIconName(baseName(f.path))} size={14} />
<Icon name={fileIconName(baseName(f.path), f.path)} size={14} />
<span
className={styles.fileName}
style={{
+2 -2
Переглянути файл
@@ -116,7 +116,7 @@ export function FileChecklist({
onChange(n);
}}
/>
<Icon name={fileIconName(baseName(f.path))} size={14} />
<Icon name={fileIconName(baseName(f.path), f.path)} size={14} />
<span className={styles.fileName} style={{ color: KIND_COLOR[kind] }}>
{baseName(stripSlash(f.path))}
</span>
@@ -517,7 +517,7 @@ export function ShelfPanel() {
renderNode={(n) =>
n.file ? (
<>
<Icon name={fileIconName(baseName(n.file.path))} size={16} className={viewStyles.icon} />
<Icon name={fileIconName(baseName(n.file.path), n.file.path)} size={16} className={viewStyles.icon} />
<span
className={viewStyles.label}
style={{ color: KIND_COLOR[n.file.kind === "untracked" ? "added" : n.file.kind] }}
+1 -1
Переглянути файл
@@ -126,7 +126,7 @@ export function presentationIcon(
(spring || role === "resources" || role === "testResources")
)
return "spring-config";
if (!info?.kind) return fileIconName(name);
if (!info?.kind) return fileIconName(name, path);
if (info.springBoot) return "spring-boot";
if (/(Tests?|[a-z\d]IT)\.(java|kt)$/.test(name) || role === "test") return "jvm-test";
const kind: Record<NonNullable<JvmPresentation["kind"]>, IconName> = {
+58
Переглянути файл
@@ -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<string, unknown>;
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<T>(raw: unknown, keep: (v: unknown) => v is T): Record<string, T> {
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<string, FileIconThemeOptions> {
return Object.fromEntries(
Object.entries(obj(raw))
.filter(([key, v]) => THEME_KEY.test(key) && isObj(v))
.map(([key, v]) => [key, validateIconThemeOptions(v)]),
);
}
+52
Переглянути файл
@@ -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 {
/** `<pluginId>/<themeId>`: the `appearance.fileIconTheme` value. */
id: string;
label: string;
origin?: string;
settingsPage?: string;
details?: () => Promise<IconThemeDetails | null>;
}
/** File icon themes of live plugins, for Settings › Appearance. */
+7 -1
Переглянути файл
@@ -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<T>(props: TreeProps<T>) {
onContextMenu,
speedSearch = true,
speedSearchMode = "highlight",
rowHeight = 24,
rowHeight: fixedRowHeight,
indent = 20,
className,
rowClassName,
@@ -299,6 +302,8 @@ export function Tree<T>(props: TreeProps<T>) {
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<T>(props: TreeProps<T>) {
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:
+45
Переглянути файл
@@ -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(
<Tree
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => n.label}
getChildren={() => undefined}
isExpandable={() => false}
aria-label="t"
/>,
);
const row = () => container.querySelector<HTMLElement>('[role="treeitem"]');
expect(row()?.style.height).toBe("24px");
act(() => setIconAppearance({ rowHeight: 30 }));
expect(row()?.style.height).toBe("30px");
});
});
+69
Переглянути файл
@@ -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<number | null>(() => 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<SVGFEColorMatrixElement>(`#${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);
}
+29
Переглянути файл
@@ -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);
}
+37 -3
Переглянути файл
@@ -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 <Icon> for unknown names, without React", () => {
const { container } = render(<Icon name="symbol-macro" />);
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", '<svg viewBox="0 0 16 16"><path d="M1 1h9z"/></svg>']]);
document.body.innerHTML = `<div>${iconMarkupOrFallback("chevron-right", 14)}</div>`;
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(
<>
+50 -11
Переглянути файл
@@ -539,11 +539,20 @@ const SYMBOL_ICONS: Partial<Record<SymbolKind, string>> = {
text: "fileTypes/text",
};
const NOT_SYMBOLS: ReadonlySet<string> = 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<string, string>)[name];
@@ -577,9 +586,10 @@ export function registerExternalIcons(icons: ReadonlyArray<readonly [name: strin
/** A file icon theme (plugin `iconThemes`). Each method returns an icon name, or null for the built-in one. */
export interface FileIconTheme {
file(fileName: string): string | null;
file(fileName: string, path?: string | null): string | null;
/** Plain folders only: folders with a role (source root, excluded, …) keep their built-in icons. */
folder(folderName: string, expanded: boolean): string | null;
folder(folderName: string, expanded: boolean, holdsOpenFile?: boolean, path?: string | null): string | null;
icon?(name: string): string | null;
}
let fileIconTheme: FileIconTheme | null = null;
@@ -598,8 +608,13 @@ export function setFileIconTheme(theme: FileIconTheme | null) {
}
/** The icon theme's icon for a plain folder, or null to keep the built-in one. */
export function themedFolderIconName(folderName: string, expanded = false): IconName | null {
return (fileIconTheme?.folder(folderName, expanded) ?? null) as IconName | null;
export function themedFolderIconName(
folderName: string,
expanded = false,
holdsOpenFile = false,
path: string | null = null,
): IconName | null {
return (fileIconTheme?.folder(folderName, expanded, holdsOpenFile, path) ?? null) as IconName | null;
}
// ---------------------------------------------------------------------------
@@ -698,6 +713,7 @@ export interface IconProps {
}
export function Icon({ name, size = 16, className, style, label }: IconProps) {
useFileIconRevision();
const uid = useId();
const path = iconPath(name);
const p = (path && prepare(path, size >= 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 `<svg class="${cls}" width="${size}" height="${size}" viewBox="${p.viewBox}" fill="none" aria-hidden="true" focusable="false">${html}</svg>`;
return `<svg class="${cls}" ${markupData(shown, size, className)} width="${size}" height="${size}" viewBox="${p.viewBox}" fill="none" aria-hidden="true" focusable="false">${html}</svg>`;
}
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<HTMLElement | SVGElement>("[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 `<span class="${styles.symbol}" data-kind="${kind}" aria-hidden="true">${letter}</span>`;
return `<span class="${styles.symbol}" ${markupData(name, size, className)} data-kind="${kind}" aria-hidden="true">${letter}</span>`;
}
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];
+77
Переглянути файл
@@ -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));
}
}
});
});
+219
Переглянути файл
@@ -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<T extends IndexedPattern> {
find(name: string, path: string | null): T | undefined;
}
function add<T>(map: Map<string, T[]>, 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<T extends IndexedPattern>(
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<T> {
const byExt = new Map<string, T[]>();
const byFirst = new Map<string, T[]>();
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;
}
},
};
}