дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
4fbe41194f
коміт
c87d2a6b55
55 files changed
+2549
-106
No files matched your search
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача