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

+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));
}
}
});
});