From b82fbafaaf635744db0927ee2143243a8a69888e Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:33:06 +0530 Subject: [PATCH 1/9] WIP --- src/components/fileTree/index.js | 44 +- src/lang/en-us.json | 3 + src/lib/acode.js | 3 + src/lib/fileIcons.ts | 1224 ++++++++++++++++++++++++++++++ src/lib/fileIconsBuiltin.ts | 234 ++++++ src/lib/openFolder.js | 31 +- src/lib/settings.js | 1 + src/main.js | 4 + src/settings/appSettings.js | 24 + src/utils/helpers.js | 77 +- tests/unit/fileIconTheme.test.ts | 214 ++++++ 11 files changed, 1797 insertions(+), 62 deletions(-) create mode 100644 src/lib/fileIcons.ts create mode 100644 src/lib/fileIconsBuiltin.ts create mode 100644 tests/unit/fileIconTheme.test.ts diff --git a/src/components/fileTree/index.js b/src/components/fileTree/index.js index 0250b1c019..abac307981 100644 --- a/src/components/fileTree/index.js +++ b/src/components/fileTree/index.js @@ -2,6 +2,7 @@ import "./style.scss"; import tile from "components/tile"; import VirtualList from "components/virtualList"; import tag from "html-tag-js"; +import fileIcons from "lib/fileIcons"; import helpers from "utils/helpers"; import Path from "utils/Path"; @@ -35,6 +36,7 @@ export default class FileTree { this.isLoading = false; this.childTrees = new Map(); // Track child trees for cleanup this.depth = options._depth || 0; // Internal: nesting depth + this._offIcons = fileIcons.onChange(() => this.applyIcons()); } /** @@ -127,6 +129,12 @@ export default class FileTree { $title.dataset.name = name; const textEl = $title.querySelector(".text"); if (textEl) textEl.textContent = name; + const iconEl = $title.querySelector("span:first-child"); + if (iconEl) { + iconEl.className = helpers.getIconForFolder(name, { + expanded: false, + }); + } // Collapse if expanded and clear children if (!recycledEl.classList.contains("hidden")) { @@ -149,7 +157,9 @@ export default class FileTree { }); $wrapper._folderUrl = url; - const $indicator = tag("span", { className: "icon folder" }); + const $indicator = tag("span", { + className: helpers.getIconForFolder(name, { expanded: false }), + }); const $title = tile({ lead: $indicator, @@ -175,6 +185,9 @@ export default class FileTree { if (isExpanded) { // Collapse $wrapper.classList.add("hidden"); + $indicator.className = helpers.getIconForFolder(name, { + expanded: false, + }); if (childTree) { childTree.destroy(); @@ -186,6 +199,9 @@ export default class FileTree { } else { // Expand $wrapper.classList.remove("hidden"); + $indicator.className = helpers.getIconForFolder(name, { + expanded: true, + }); $title.classList.add("loading"); // Create child tree with incremented depth @@ -314,10 +330,36 @@ export default class FileTree { * Destroy the file tree and cleanup */ destroy() { + this._offIcons?.(); + this._offIcons = null; this.clear(); this.container.classList.remove("file-tree"); } + applyIcons() { + for (const $file of this.container.querySelectorAll( + ':scope > [data-type="file"][data-name]', + )) { + const $icon = $file.querySelector(":scope > span:first-child"); + if ($icon) { + $icon.className = helpers.getIconForFile($file.dataset.name); + } + } + + for (const $folder of this.container.querySelectorAll( + '[data-type="dir"][data-name]', + )) { + const $icon = $folder.querySelector(":scope > span:first-child"); + if (!$icon) continue; + const expanded = !$folder + .closest(".collapsible") + ?.classList.contains("hidden"); + $icon.className = helpers.getIconForFolder($folder.dataset.name, { + expanded, + }); + } + } + /** * Find an entry element by URL * @param {string} url diff --git a/src/lang/en-us.json b/src/lang/en-us.json index 0393c9e451..da0d83982c 100644 --- a/src/lang/en-us.json +++ b/src/lang/en-us.json @@ -174,6 +174,9 @@ "light": "Light", "dark": "Dark", "file browser": "File Browser", + "icon theme": "Icon theme", + "settings-info-icon-theme": "Choose how files and folders are shown in the explorer and file lists. Plugin icon themes become available after they load.", + "unavailable": "unavailable", "operation not permitted": "Operation not permitted", "no such file or directory": "No such file or directory", "input/output error": "Input/output error", diff --git a/src/lib/acode.js b/src/lib/acode.js index d5a0f2de62..73c88e2e6b 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -51,6 +51,7 @@ import windowResize from "handlers/windowResize"; import actionStack from "lib/actionStack"; import commands from "lib/commands"; import EditorFile from "lib/editorFile"; +import fileIcons from "lib/fileIcons"; import fileIndex from "lib/fileIndex"; import files from "lib/fileList"; import fileTypeHandler from "lib/fileTypeHandler"; @@ -403,6 +404,7 @@ class Acode { deprecatedFileList.replacement = "fileIndex"; this.define("fileList", deprecatedFileList); this.define("fileIndex", fileIndex); + this.define("fileIcons", fileIcons); this.define("fs", fsOperation); this.define("confirm", confirm); this.define("helpers", helpers); @@ -785,6 +787,7 @@ class Acode { } delete appSettings.uiSettings[`plugin-${id}`]; + fileIcons.unregisterByPlugin(id); } registerFormatter(id, extensions, format, displayName) { diff --git a/src/lib/fileIcons.ts b/src/lib/fileIcons.ts new file mode 100644 index 0000000000..a9177b2d09 --- /dev/null +++ b/src/lib/fileIcons.ts @@ -0,0 +1,1224 @@ +import { getModeForPath } from "cm/modelist"; +import { + BUILTIN_THEME_ID, + createBuiltinTheme, + SCHEMA_VERSION, +} from "./fileIconsBuiltin"; + +const THEME_ID_RE = /^[a-zA-Z][a-zA-Z0-9._-]*$/; +const UNSAFE_SRC_RE = /[\s"'()\\]/; + +export type IconKind = "file" | "folder"; +export type IconMatchSource = + | "override" + | "fileName" + | "fileExtension" + | "languageId" + | "folderName" + | "default"; + +export interface IconDefinition { + className?: string; + expandedClassName?: string; + src?: string; + expandedSrc?: string; + light?: string; + dark?: string; + monochrome?: boolean; + iconPath?: string; +} + +export interface IconAssociations { + fileNames?: Record; + fileExtensions?: Record; + languageIds?: Record; + folderNames?: Record; + folderNamesExpanded?: Record; +} + +export interface IconDefaults { + file?: string; + folder?: string; + folderExpanded?: string; + rootFolder?: string; + rootFolderExpanded?: string; +} + +/** VS Code / Zed-style icon theme. `icons` may be a folder URL of SVG files. */ +export interface FileIconTheme extends IconAssociations, IconDefaults { + id: string; + name?: string; + label?: string; + schemaVersion?: number; + pluginId?: string; + baseUrl?: string; + icons?: string | Record; + iconDefinitions?: Record; + associations?: IconAssociations; + defaults?: IconDefaults; +} + +export interface IconResource { + kind?: IconKind; + name: string; + languageId?: string; + expanded?: boolean; + isRoot?: boolean; + appearance?: "dark" | "light"; +} + +export interface IconHandle { + className: string; + iconId: string; + source: IconMatchSource; + kind: IconKind; + themeId: string; + expanded?: boolean; +} + +export interface IconThemeInfo { + id: string; + label: string; + available: boolean; + pluginId: string | null; +} + +export interface ActiveIconTheme { + id: string; + preferredId: string; + label: string; + available: boolean; +} + +interface CompiledTheme { + id: string; + label: string; + pluginId: string | null; + schemaVersion: number; + icons: Map; + fileNames: Map; + fileNamesCi: Map; + fileExtensions: Map; + languageIds: Map; + folderNames: Map; + folderNamesExpanded: Map; + defaults: Required; +} + +interface RegisterOptions { + builtin?: boolean; + pluginId?: string; + silent?: boolean; +} + +interface IconThemeSettings { + value?: { iconTheme?: string }; + on?: (event: string, callback: (value: unknown) => void) => void; + update?: (showToast?: boolean) => void; +} + +interface OverrideRule { + kind?: IconKind; + name: string; + icon: string; + caseSensitive?: boolean; +} + +type NormalizedResource = IconResource & { kind: IconKind; name: string }; + +function basename(value: unknown): string { + const str = String(value ?? ""); + if (!str) return ""; + const trimmed = + str.endsWith("/") || str.endsWith("\\") ? str.slice(0, -1) : str; + const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +function sanitizeClassToken(value: unknown): string { + return String(value ?? "") + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function assertThemeId(id: unknown): string { + if (typeof id !== "string" || !id.trim()) { + throw new Error("Icon theme id is required"); + } + if (!THEME_ID_RE.test(id)) { + throw new Error(`Invalid icon theme id '${id}'`); + } + return id; +} + +function isSafeSrc(src: unknown): src is string { + if (typeof src !== "string") return false; + const value = src.trim(); + if (!value || UNSAFE_SRC_RE.test(value)) return false; + if (value.startsWith("data:image/")) return true; + return ( + /^(https?:|blob:|file:|content:|ftp:)/i.test(value) || value.startsWith("/") + ); +} + +function joinUrl(base: string, path: string): string { + const rel = String(path || "").replace(/^\.\//, ""); + if (!base) return rel; + return `${String(base).replace(/\/?$/, "/")}${rel.replace(/^\//, "")}`; +} + +function resolveAssetPath( + path: string, + iconsDir: string | null, + baseUrl?: string, +): string { + const value = path.trim(); + if (!value) return ""; + if ( + /^(https?:|data:|blob:|file:|content:)/i.test(value) || + value.startsWith("/") + ) { + return value; + } + return joinUrl(iconsDir || baseUrl || "", value); +} + +function getDocument(): Document | null { + return typeof document !== "undefined" ? document : null; +} + +function inferLanguageId(filename: string): string | undefined { + try { + return getModeForPath?.(filename)?.name || undefined; + } catch { + return undefined; + } +} + +export function buildBuiltinFileClass( + typeId: string, + languageId?: string, +): string { + const type = sanitizeClassToken(typeId) || "default"; + const mode = sanitizeClassToken(languageId || "text") || "text"; + return `file file_type_default file_type_${mode} file_type_${type}`; +} + +export function buildBuiltinFolderClass(_folderId?: string): string { + return "icon folder"; +} + +function iconIdFromAssoc(value: unknown): string { + if (typeof value === "string") return value; + if ( + value && + typeof value === "object" && + "icon" in value && + typeof value.icon === "string" + ) { + return value.icon; + } + throw new Error("Association values must be icon ids"); +} + +function collectIconIds(theme: FileIconTheme): Set { + const ids = new Set(); + const add = (value: unknown) => { + if (typeof value === "string" && value) ids.add(value); + }; + const addMap = (map?: Record) => { + if (!map) return; + for (const value of Object.values(map)) add(value); + }; + addMap(theme.fileNames); + addMap(theme.fileExtensions); + addMap(theme.languageIds); + addMap(theme.folderNames); + addMap(theme.folderNamesExpanded); + addMap(theme.associations?.fileNames); + addMap(theme.associations?.fileExtensions); + addMap(theme.associations?.languageIds); + addMap(theme.associations?.folderNames); + addMap(theme.associations?.folderNamesExpanded); + add(theme.file); + add(theme.folder); + add(theme.folderExpanded); + add(theme.rootFolder); + add(theme.rootFolderExpanded); + add(theme.defaults?.file); + add(theme.defaults?.folder); + add(theme.defaults?.folderExpanded); + add(theme.defaults?.rootFolder); + add(theme.defaults?.rootFolderExpanded); + return ids; +} + +function folderIconIds(theme: FileIconTheme): Set { + const ids = new Set(); + const add = (value?: string) => { + if (value) ids.add(value); + }; + const addMap = (map?: Record) => { + if (!map) return; + for (const value of Object.values(map)) add(value); + }; + addMap(theme.folderNames); + addMap(theme.folderNamesExpanded); + addMap(theme.associations?.folderNames); + addMap(theme.associations?.folderNamesExpanded); + add(theme.folder); + add(theme.folderExpanded); + add(theme.rootFolder); + add(theme.rootFolderExpanded); + add(theme.defaults?.folder); + add(theme.defaults?.folderExpanded); + add(theme.defaults?.rootFolder); + add(theme.defaults?.rootFolderExpanded); + return ids; +} + +function fromVsCodeIcon( + def: IconDefinition | string, + iconsDir: string | null, + baseUrl?: string, +): IconDefinition | string { + if (typeof def === "string") { + if (isSafeSrc(def)) return resolveAssetPath(def, iconsDir, baseUrl); + return def; + } + const iconPath = def.iconPath || def.src; + if (!iconPath) return def; + const next: IconDefinition = { + src: resolveAssetPath(iconPath, iconsDir, baseUrl), + }; + if (def.expandedSrc) { + next.expandedSrc = resolveAssetPath(def.expandedSrc, iconsDir, baseUrl); + } + if (def.light) next.light = resolveAssetPath(def.light, iconsDir, baseUrl); + if (def.dark) next.dark = resolveAssetPath(def.dark, iconsDir, baseUrl); + if (def.className) next.className = def.className; + if (def.expandedClassName) next.expandedClassName = def.expandedClassName; + if (def.monochrome) next.monochrome = true; + return next; +} + +export function prepareTheme(input: FileIconTheme): FileIconTheme { + const theme: FileIconTheme = { ...input }; + if (typeof theme.name === "string" && !theme.label) { + theme.label = theme.name; + } + + let iconsDir: string | null = null; + if (typeof theme.icons === "string") { + iconsDir = theme.icons.replace(/\/?$/, "/"); + theme.icons = {}; + } else if ( + theme.icons && + typeof theme.icons === "object" && + !Array.isArray(theme.icons) + ) { + theme.icons = { ...theme.icons }; + } else { + theme.icons = {}; + } + + if (typeof theme.baseUrl === "string" && !iconsDir) { + iconsDir = joinUrl(theme.baseUrl, "icons/"); + } + + const icons = theme.icons; + if (theme.iconDefinitions) { + for (const [id, def] of Object.entries(theme.iconDefinitions)) { + icons[id] = fromVsCodeIcon(def, iconsDir, theme.baseUrl); + } + } + + const folders = folderIconIds(theme); + for (const id of collectIconIds(theme)) { + if (icons[id] || !iconsDir) continue; + const def: IconDefinition = { src: joinUrl(iconsDir, `${id}.svg`) }; + if ( + !id.endsWith("-open") && + (id.startsWith("folder") || folders.has(id)) + ) { + def.expandedSrc = joinUrl(iconsDir, `${id}-open.svg`); + } + icons[id] = def; + } + + return theme; +} + +function normalizeAssocKey(key: string, kind: string): string { + const value = String(key ?? "").trim(); + if (!value) throw new Error(`Empty ${kind} association`); + if (kind === "fileExtension") return value.replace(/^\./, "").toLowerCase(); + if (kind === "languageId" || kind === "folderName") return value.toLowerCase(); + return value; +} + +function addAssociations( + map: Map, + source: Record | undefined, + kind: string, + options: { caseInsensitive?: boolean } = {}, +): void { + if (!source) return; + if (typeof source !== "object" || Array.isArray(source)) { + throw new Error(`${kind} associations must be an object`); + } + for (const [rawKey, rawValue] of Object.entries(source)) { + const key = options.caseInsensitive + ? normalizeAssocKey(rawKey, kind) + : normalizeAssocKey( + rawKey, + kind === "fileExtension" ? "fileExtension" : "fileName", + ); + map.set(key, iconIdFromAssoc(rawValue)); + } +} + +function assetClassName(themeId: string, iconId: string, variant = ""): string { + const suffix = variant ? `-${variant}` : ""; + return `file-icon--${sanitizeClassToken(themeId)}--${sanitizeClassToken(iconId)}${suffix}`; +} + +function cssForSrc( + className: string, + src: string, + monochrome: boolean, +): string { + const host = `.icon.${className}{display:inline-flex;align-items:center;justify-content:center;background:none;}`; + if (monochrome) { + return `${host}.icon.${className}::before{content:'';display:block;width:1em;height:1em;-webkit-mask:url(${src}) no-repeat center / contain;mask:url(${src}) no-repeat center / contain;background-color:currentColor;}`; + } + return `${host}.icon.${className}::before{content:'';display:block;width:1em;height:1em;background:url(${src}) no-repeat center / contain;}`; +} + +function normalizeIconDef(id: string, def: unknown): IconDefinition { + if (typeof def === "string") { + return isSafeSrc(def) ? { src: def.trim() } : { className: def }; + } + if (!def || typeof def !== "object" || Array.isArray(def)) { + throw new Error(`Invalid icon definition '${id}'`); + } + const rec = def as IconDefinition; + const normalized: IconDefinition = {}; + if (typeof rec.className === "string" && rec.className.trim()) { + normalized.className = rec.className.trim(); + } + if (typeof rec.expandedClassName === "string" && rec.expandedClassName.trim()) { + normalized.expandedClassName = rec.expandedClassName.trim(); + } + for (const key of ["src", "expandedSrc", "light", "dark"] as const) { + const value = rec[key]; + if (value == null) continue; + if (!isSafeSrc(value)) { + throw new Error(`Unsafe icon asset for '${id}.${key}'`); + } + normalized[key] = value.trim(); + } + if (rec.monochrome) normalized.monochrome = true; + if ( + !normalized.className && + !normalized.src && + !normalized.light && + !normalized.dark + ) { + throw new Error(`Icon '${id}' needs className or src`); + } + return normalized; +} + +function compileTheme(input: FileIconTheme): CompiledTheme { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Icon theme must be an object"); + } + const theme = prepareTheme(input); + const id = assertThemeId(theme.id); + const schemaVersion = theme.schemaVersion ?? SCHEMA_VERSION; + if (schemaVersion !== SCHEMA_VERSION) { + throw new Error( + `Unsupported icon theme schemaVersion ${schemaVersion} (expected ${SCHEMA_VERSION})`, + ); + } + + const icons = new Map(); + if (theme.icons && typeof theme.icons === "object") { + for (const [iconId, def] of Object.entries(theme.icons)) { + if (!sanitizeClassToken(iconId)) throw new Error("Icon id is required"); + icons.set(iconId, normalizeIconDef(iconId, def)); + } + } + + const associations = theme.associations || {}; + const fileNames = new Map(); + const fileNamesCi = new Map(); + const fileExtensions = new Map(); + const languageIds = new Map(); + const folderNames = new Map(); + const folderNamesExpanded = new Map(); + + addAssociations(fileNames, theme.fileNames || associations.fileNames, "fileName"); + for (const [key, iconId] of fileNames) { + const lower = key.toLowerCase(); + if (!fileNamesCi.has(lower)) fileNamesCi.set(lower, iconId); + } + addAssociations( + fileExtensions, + theme.fileExtensions || associations.fileExtensions, + "fileExtension", + { caseInsensitive: true }, + ); + addAssociations( + languageIds, + theme.languageIds || associations.languageIds, + "languageId", + { caseInsensitive: true }, + ); + addAssociations( + folderNames, + theme.folderNames || associations.folderNames, + "folderName", + { caseInsensitive: true }, + ); + addAssociations( + folderNamesExpanded, + theme.folderNamesExpanded || associations.folderNamesExpanded, + "folderName", + { caseInsensitive: true }, + ); + + return { + id, + label: String(theme.label || theme.name || id), + pluginId: theme.pluginId || null, + schemaVersion, + icons, + fileNames, + fileNamesCi, + fileExtensions, + languageIds, + folderNames, + folderNamesExpanded, + defaults: { + file: theme.defaults?.file || theme.file || "file", + folder: theme.defaults?.folder || theme.folder || "folder", + folderExpanded: + theme.defaults?.folderExpanded || theme.folderExpanded || "folder", + rootFolder: theme.defaults?.rootFolder || theme.rootFolder || "folder", + rootFolderExpanded: + theme.defaults?.rootFolderExpanded || + theme.rootFolderExpanded || + theme.defaults?.rootFolder || + theme.rootFolder || + "folder", + }, + }; +} + +function matchExtension( + name: string, + extensions: Map, +): string | undefined { + const lower = name.toLowerCase(); + const parts = lower.split("."); + if (parts.length < 2) return undefined; + for (let i = 1; i < parts.length; i++) { + const ext = parts.slice(i).join("."); + if (ext && extensions.has(ext)) return extensions.get(ext); + } + return undefined; +} + +function lastExtension(name: string): string { + const lower = name.toLowerCase(); + if (lower.startsWith(".") && lower.indexOf(".", 1) === -1) return ""; + const parts = lower.split("."); + if (parts.length < 2) return ""; + return parts[parts.length - 1] || ""; +} + +class FileIconRegistry { + #themes = new Map(); + #compiled = new Map(); + #overrides = new Map(); + #listeners = new Set<(info: { activeId: string; preferredId: string }) => void>(); + #activeId = BUILTIN_THEME_ID; + #preferredId = BUILTIN_THEME_ID; + #settings: IconThemeSettings | null = null; + + constructor() { + this.#putTheme(createBuiltinTheme() as FileIconTheme, { + builtin: true, + silent: true, + }); + this.#activeId = BUILTIN_THEME_ID; + this.#preferredId = BUILTIN_THEME_ID; + } + + bindSettings(settings: IconThemeSettings): void { + this.#settings = settings; + settings?.on?.("update:iconTheme", (value) => { + this.use(typeof value === "string" ? value : BUILTIN_THEME_ID, { + persist: false, + }); + }); + } + + syncFromSettings(): void { + const id = this.#settings?.value?.iconTheme; + if (typeof id === "string" && id) this.use(id, { persist: false }); + } + + /** + * Register or replace an icon theme. If `icons` is a folder URL, referenced + * ids resolve to `/.svg` (and `-open.svg` for folders). + */ + register(theme: FileIconTheme, options: RegisterOptions = {}): { dispose: () => void } { + const compiled = compileTheme(theme); + if (compiled.id === BUILTIN_THEME_ID && !options.builtin) { + throw new Error("Cannot replace the built-in icon theme"); + } + if (this.#themes.has(compiled.id) && compiled.id !== BUILTIN_THEME_ID) { + this.update(compiled.id, theme); + return { dispose: () => this.unregister(compiled.id) }; + } + this.#putTheme(theme, options); + return { dispose: () => this.unregister(compiled.id) }; + } + + /** @deprecated Use register() */ + registerTheme(theme: FileIconTheme, options: RegisterOptions = {}) { + return this.register(theme, options); + } + + update(id: string, theme: FileIconTheme): void { + if (id === BUILTIN_THEME_ID) { + throw new Error("Cannot update the built-in icon theme"); + } + if (!this.#themes.has(id)) { + throw new Error(`Icon theme '${id}' is not registered`); + } + const previous = this.#compiled.get(id); + this.#putTheme( + { + ...theme, + id, + pluginId: theme.pluginId || previous?.pluginId || undefined, + }, + {}, + ); + } + + /** @deprecated Use update() */ + updateTheme(id: string, theme: FileIconTheme): void { + this.update(id, theme); + } + + unregister(id: string): boolean { + if (id === BUILTIN_THEME_ID) return false; + if (!this.#themes.has(id)) return false; + this.#themes.delete(id); + this.#compiled.delete(id); + this.#removeThemeStyles(id); + if (this.#activeId === id) { + this.#activeId = BUILTIN_THEME_ID; + this.#emitChange(); + } + return true; + } + + /** @deprecated Use unregister() */ + unregisterTheme(id: string): boolean { + return this.unregister(id); + } + + unregisterByPlugin(pluginId: string): void { + if (!pluginId) return; + for (const [id, compiled] of [...this.#compiled]) { + if (compiled.pluginId === pluginId) this.unregister(id); + } + } + + list(): IconThemeInfo[] { + const list: IconThemeInfo[] = []; + for (const compiled of this.#compiled.values()) { + list.push({ + id: compiled.id, + label: compiled.label, + available: true, + pluginId: compiled.pluginId, + }); + } + if (this.#preferredId && !this.#compiled.has(this.#preferredId)) { + list.push({ + id: this.#preferredId, + label: this.#preferredId, + available: false, + pluginId: null, + }); + } + return list; + } + + /** @deprecated Use list() */ + listThemes(): IconThemeInfo[] { + return this.list(); + } + + active(): ActiveIconTheme { + const compiled = this.#compiled.get(this.#activeId); + return { + id: this.#activeId, + preferredId: this.#preferredId, + label: compiled?.label || this.#activeId, + available: this.#compiled.has(this.#preferredId), + }; + } + + /** @deprecated Use active() */ + getActiveTheme(): ActiveIconTheme { + return this.active(); + } + + use(id: string, options: { persist?: boolean } = {}): ActiveIconTheme { + const next = + typeof id === "string" && id.trim() ? id.trim() : BUILTIN_THEME_ID; + const persist = options.persist !== false; + const preferredChanged = next !== this.#preferredId; + this.#preferredId = next; + const resolved = this.#compiled.has(next) ? next : BUILTIN_THEME_ID; + const activeChanged = resolved !== this.#activeId; + this.#activeId = resolved; + if (persist) this.#persistPreferred(next); + if (preferredChanged || activeChanged) this.#emitChange(); + return this.active(); + } + + /** @deprecated Use use() */ + setPreferredTheme(id: string, options: { persist?: boolean } = {}) { + return this.use(id, options); + } + + /** @deprecated Use use() */ + setActiveTheme(id: string) { + return this.use(id); + } + + setOverride(rule: OverrideRule): { dispose: () => void } { + if (!rule || typeof rule.name !== "string" || !rule.name) { + throw new Error("Override name is required"); + } + const kind = rule.kind === "folder" ? "folder" : "file"; + this.#overrides.set(overrideKey(kind, rule.name, rule.caseSensitive !== false), { + icon: String(rule.icon || ""), + kind, + name: rule.name, + caseSensitive: rule.caseSensitive !== false, + }); + this.#emitChange(); + return { dispose: () => this.removeOverride(rule) }; + } + + removeOverride(rule: Pick): boolean { + if (!rule?.name) return false; + const kind = rule.kind === "folder" ? "folder" : "file"; + return this.#overrides.delete( + overrideKey(kind, rule.name, rule.caseSensitive !== false), + ); + } + + resolve(resource: IconResource | string): IconHandle { + const input = normalizeResource(resource); + const compiled = + this.#compiled.get(this.#activeId) || + this.#compiled.get(BUILTIN_THEME_ID); + const builtin = this.#compiled.get(BUILTIN_THEME_ID); + if (!compiled || !builtin) { + return { + className: buildBuiltinFileClass("default"), + iconId: "default", + source: "default", + kind: input.kind, + themeId: BUILTIN_THEME_ID, + }; + } + const languageId = input.languageId || inferLanguageId(input.name); + if (input.kind === "folder") { + return this.#resolveFolder(input, compiled, builtin); + } + return this.#resolveFile(input, compiled, builtin, languageId); + } + + resolveMany(resources: Array): IconHandle[] { + if (!Array.isArray(resources)) return []; + return resources.map((resource) => this.resolve(resource)); + } + + icon(resource: IconResource | string): string { + return this.resolve(resource).className; + } + + getIconClass(resource: IconResource | string): string { + return this.icon(resource); + } + + onChange( + listener: (info: { activeId: string; preferredId: string }) => void, + ): () => void { + if (typeof listener !== "function") return () => {}; + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + onDidChange( + listener: (info: { activeId: string; preferredId: string }) => void, + ): () => void { + return this.onChange(listener); + } + + refreshRenderedIcons(): void { + const doc = getDocument(); + if (!doc) return; + + const apply = () => { + for (const $tile of doc.querySelectorAll( + '[data-type="file"][data-name]', + )) { + applyLeadClass( + $tile, + this.icon({ kind: "file", name: $tile.dataset.name || "" }), + ); + } + + for (const $tile of doc.querySelectorAll( + '[data-type="dir"][data-name], [data-type="root"][data-name]', + )) { + const expanded = !$tile + .closest(".collapsible") + ?.classList.contains("hidden"); + applyLeadClass( + $tile, + this.icon({ + kind: "folder", + name: $tile.dataset.name || "", + expanded, + isRoot: $tile.dataset.type === "root", + }), + ); + } + + this.#refreshEditorTabs(); + }; + + apply(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(apply); + } + } + + resetForTests(): void { + for (const id of [...this.#themes.keys()]) { + if (id !== BUILTIN_THEME_ID) this.unregister(id); + } + this.#overrides.clear(); + this.#listeners.clear(); + this.#preferredId = BUILTIN_THEME_ID; + this.#activeId = BUILTIN_THEME_ID; + this.#settings = null; + } + + #putTheme(theme: FileIconTheme, options: RegisterOptions): CompiledTheme { + const compiled = compileTheme(theme); + if (options.pluginId && !compiled.pluginId) { + compiled.pluginId = options.pluginId; + } + this.#themes.set(compiled.id, { ...theme, pluginId: compiled.pluginId || undefined }); + this.#compiled.set(compiled.id, compiled); + this.#applyThemeStyles(compiled); + + const becameActive = + compiled.id === this.#preferredId && this.#activeId !== compiled.id; + if (becameActive) this.#activeId = compiled.id; + if (!options.silent && (becameActive || compiled.id === this.#activeId)) { + this.#emitChange(); + } + return compiled; + } + + #persistPreferred(id: string): void { + if (!this.#settings?.value) return; + if (this.#settings.value.iconTheme === id) return; + this.#settings.value.iconTheme = id; + this.#settings.update?.(false); + } + + #emitChange(): void { + const info = { activeId: this.#activeId, preferredId: this.#preferredId }; + for (const listener of this.#listeners) { + try { + listener(info); + } catch (error) { + console.warn("[fileIcons] onChange listener failed:", error); + } + } + this.refreshRenderedIcons(); + } + + #refreshEditorTabs(): void { + const files = + typeof window !== "undefined" + ? ( + window as unknown as { + editorManager?: { + files?: Array<{ + tab?: HTMLElement; + filename?: string; + type?: string; + }>; + }; + } + ).editorManager?.files + : null; + if (!Array.isArray(files)) return; + for (const file of files) { + const $tab = file?.tab; + if (!$tab || !file.filename) continue; + const $lead = $tab.firstElementChild; + if ( + !$lead || + $lead.classList.contains("text") || + $lead.classList.contains("cancel") + ) { + continue; + } + if (file.type && file.type !== "editor") continue; + $lead.className = this.icon({ kind: "file", name: file.filename }); + } + } + + #resolveFile( + input: NormalizedResource, + compiled: CompiledTheme, + builtin: CompiledTheme, + languageId?: string, + ): IconHandle { + const name = input.name; + const override = this.#matchOverride("file", name); + if (override) { + return this.#handleFromIcon( + compiled, + builtin, + override.icon, + "override", + input, + languageId, + ); + } + + const exact = compiled.fileNames.get(name); + if (exact) { + return this.#handleFromIcon( + compiled, + builtin, + exact, + "fileName", + input, + languageId, + ); + } + const ci = compiled.fileNamesCi.get(name.toLowerCase()); + if (ci) { + return this.#handleFromIcon( + compiled, + builtin, + ci, + "fileName", + input, + languageId, + ); + } + + const byExt = matchExtension(name, compiled.fileExtensions); + if (byExt) { + return this.#handleFromIcon( + compiled, + builtin, + byExt, + "fileExtension", + input, + languageId, + ); + } + + const langKey = languageId ? languageId.toLowerCase() : ""; + if (langKey && compiled.languageIds.has(langKey)) { + return this.#handleFromIcon( + compiled, + builtin, + compiled.languageIds.get(langKey) || "", + "languageId", + input, + languageId, + ); + } + + if (compiled.id === BUILTIN_THEME_ID) { + const ext = lastExtension(name); + const typeId = ext || "default"; + return { + className: buildBuiltinFileClass(typeId, languageId), + iconId: typeId, + source: ext ? "fileExtension" : "default", + kind: "file", + themeId: compiled.id, + }; + } + + return this.#handleFromIcon( + compiled, + builtin, + compiled.defaults.file || "file", + "default", + input, + languageId, + ); + } + + #resolveFolder( + input: NormalizedResource, + compiled: CompiledTheme, + builtin: CompiledTheme, + ): IconHandle { + const override = this.#matchOverride("folder", input.name); + if (override) { + return this.#handleFromIcon( + compiled, + builtin, + override.icon, + "override", + input, + ); + } + + const key = input.name.toLowerCase(); + if (input.expanded && compiled.folderNamesExpanded.has(key)) { + return this.#handleFromIcon( + compiled, + builtin, + compiled.folderNamesExpanded.get(key) || "", + "folderName", + input, + ); + } + if (compiled.folderNames.has(key)) { + return this.#handleFromIcon( + compiled, + builtin, + compiled.folderNames.get(key) || "", + "folderName", + input, + ); + } + + let defaultId = compiled.defaults.folder || "folder"; + if (input.isRoot) { + defaultId = input.expanded + ? compiled.defaults.rootFolderExpanded || defaultId + : compiled.defaults.rootFolder || defaultId; + } else if (input.expanded) { + defaultId = compiled.defaults.folderExpanded || defaultId; + } + + return this.#handleFromIcon(compiled, builtin, defaultId, "default", input); + } + + #matchOverride(kind: IconKind, name: string): OverrideRule | undefined { + return ( + this.#overrides.get(overrideKey(kind, name, true)) || + this.#overrides.get(overrideKey(kind, name, false)) + ); + } + + #handleFromIcon( + compiled: CompiledTheme, + builtin: CompiledTheme, + iconId: string, + source: IconMatchSource, + input: NormalizedResource, + languageId?: string, + ): IconHandle { + const def = compiled.icons.get(iconId); + const className = this.#classNameFor( + compiled, + def, + iconId, + input, + languageId, + ); + if (className) { + return { + className, + iconId, + source, + kind: input.kind, + themeId: compiled.id, + expanded: input.expanded, + }; + } + + if (compiled.id !== BUILTIN_THEME_ID && source !== "default") { + const fallbackId = + input.kind === "folder" + ? input.expanded + ? compiled.defaults.folderExpanded || "folder" + : compiled.defaults.folder || "folder" + : compiled.defaults.file || "file"; + return this.#handleFromIcon( + compiled, + builtin, + fallbackId, + "default", + input, + languageId, + ); + } + + if (compiled.id !== BUILTIN_THEME_ID) { + return this.#handleFromIcon( + builtin, + builtin, + input.kind === "folder" ? "folder" : "file", + "default", + input, + languageId, + ); + } + + return { + className: + input.kind === "folder" + ? buildBuiltinFolderClass() + : buildBuiltinFileClass("default", languageId), + iconId: iconId || "default", + source, + kind: input.kind, + themeId: BUILTIN_THEME_ID, + expanded: input.expanded, + }; + } + + #classNameFor( + compiled: CompiledTheme, + def: IconDefinition | undefined, + iconId: string, + input: NormalizedResource, + languageId?: string, + ): string { + if (def) { + if (input.kind === "folder" && input.expanded) { + if (def.expandedClassName) return def.expandedClassName; + if (def.expandedSrc) { + return `icon ${assetClassName(compiled.id, iconId, "expanded")}`; + } + } + if (def.className) return def.className; + if (def.src || def.light || def.dark) { + return `icon ${assetClassName(compiled.id, iconId)}`; + } + } + + if (compiled.id === BUILTIN_THEME_ID) { + if (input.kind === "folder") return buildBuiltinFolderClass(); + if (iconId === "file") return buildBuiltinFileClass("default", languageId); + return buildBuiltinFileClass(iconId, languageId); + } + + return ""; + } + + #applyThemeStyles(compiled: CompiledTheme): void { + const doc = getDocument(); + if (!doc) return; + + const rules: string[] = []; + for (const [iconId, def] of compiled.icons) { + const src = pickSrc(def); + if (src) { + rules.push( + cssForSrc(assetClassName(compiled.id, iconId), src, !!def.monochrome), + ); + } + if (def.expandedSrc) { + rules.push( + cssForSrc( + assetClassName(compiled.id, iconId, "expanded"), + def.expandedSrc, + !!def.monochrome, + ), + ); + } + } + + let style = doc.head.querySelector(`style[data-file-icon="${compiled.id}"]`); + if (!rules.length) { + style?.remove(); + return; + } + if (!style) { + style = doc.createElement("style"); + style.setAttribute("data-file-icon", compiled.id); + doc.head.appendChild(style); + } + style.textContent = rules.join("\n"); + } + + #removeThemeStyles(id: string): void { + getDocument() + ?.head.querySelector(`style[data-file-icon="${id}"]`) + ?.remove(); + } +} + +function overrideKey( + kind: IconKind, + name: string, + caseSensitive: boolean, +): string { + return `${kind}:${caseSensitive ? name : name.toLowerCase()}`; +} + +function normalizeResource(resource: IconResource | string): NormalizedResource { + if (typeof resource === "string") { + return { kind: "file", name: basename(resource) }; + } + const kind = resource?.kind === "folder" ? "folder" : "file"; + return { + ...resource, + kind, + name: basename(resource?.name || ""), + }; +} + +function applyLeadClass($tile: HTMLElement, className: string): void { + const $lead = + $tile.querySelector(":scope > span:first-child") || + ($tile.firstElementChild as HTMLElement | null); + if (!$lead || $lead.classList.contains("text") || $lead.classList.contains("tail")) { + return; + } + $lead.className = className; +} + +function pickSrc(def: IconDefinition, appearance?: "dark" | "light"): string { + if (appearance === "light" && def.light) return def.light; + if (appearance === "dark" && def.dark) return def.dark; + return def.src || def.dark || def.light || ""; +} + +const fileIcons = new FileIconRegistry(); + +export { BUILTIN_THEME_ID, SCHEMA_VERSION }; +export default fileIcons; diff --git a/src/lib/fileIconsBuiltin.ts b/src/lib/fileIconsBuiltin.ts new file mode 100644 index 0000000000..ab0f18c352 --- /dev/null +++ b/src/lib/fileIconsBuiltin.ts @@ -0,0 +1,234 @@ +export const BUILTIN_THEME_ID = "builtin"; +export const SCHEMA_VERSION = 1; + +/** + * @param {Record} target + * @param {string} stem + * @param {string} icon + * @param {string[]} [suffixes] + */ +function assignConfigVariants( + target: Record, + stem: string, + icon: string, + suffixes = ["json", "json5", "yml", "yaml", "toml", "js", "cjs", "mjs"], +) { + target[stem] = icon; + for (const suffix of suffixes) { + target[`${stem}.${suffix}`] = icon; + } +} + +function createFileNames() { + const fileNames: Record = { + "yarn.lock": "yarn", + ".yarnrc": "yarn", + ".yarnrc.yml": "yarn", + "package.json": "npm", + "package-lock.json": "npm", + "npm-shrinkwrap.json": "npm", + ".npmrc": "npm", + ".nvmrc": "npm", + "jsconfig.json": "jsconfig", + "tsconfig.json": "tsconfig", + "tsconfig.build.json": "tsconfig", + "tsconfig.app.json": "tsconfig", + "tsconfig.node.json": "tsconfig", + "jsconfig.build.json": "jsconfig", + ".jsbeautifyrc": "jsbeautify", + "webpack.config.js": "webpack", + "webpack.config.cjs": "webpack", + "webpack.config.mjs": "webpack", + "webpack.config.ts": "webpack", + "webpack.config.babel.js": "webpack", + "rollup.config.js": "rollup", + "rollup.config.mjs": "rollup", + "rollup.config.ts": "rollup", + "tailwind.config.js": "tailwind", + "tailwind.config.cjs": "tailwind", + "tailwind.config.mjs": "tailwind", + "tailwind.config.ts": "tailwind", + ".gitignore": "git", + ".gitattributes": "git", + ".gitmodules": "git", + ".gitkeep": "git", + ".mailmap": "git", + ".dockerignore": "docker", + dockerfile: "docker", + Dockerfile: "docker", + "Dockerfile.dev": "docker", + "Dockerfile.prod": "docker", + "docker-compose.yml": "docker", + "docker-compose.yaml": "docker", + "compose.yml": "docker", + "compose.yaml": "docker", + makefile: "makefile", + Makefile: "makefile", + GNUmakefile: "makefile", + "CMakeLists.txt": "cmake", + LICENSE: "license", + LICENCE: "license", + COPYING: "license", + "LICENSE.md": "license", + "LICENSE.txt": "license", + Gemfile: "ruby", + Rakefile: "ruby", + Guardfile: "ruby", + "Cargo.toml": "rust", + "Cargo.lock": "rust", + "go.mod": "golang", + "go.sum": "golang", + "composer.json": "php", + "composer.lock": "php", + ".htaccess": "apache", + ".htpasswd": "htpasswd", + ".editorconfig": "ini", + ".babelrc": "babel", + "babel.config.js": "babel", + "babel.config.cjs": "babel", + "babel.config.json": "babel", + ".prettierrc": "prettier", + "prettier.config.js": "prettier", + "prettier.config.cjs": "prettier", + "prettier.config.mjs": "prettier", + ".eslintrc": "eslint", + "eslint.config.js": "eslint", + "eslint.config.cjs": "eslint", + "eslint.config.mjs": "eslint", + "eslint.config.ts": "eslint", + "eslint.config.json": "eslint", + ".postcssrc": "postcssconfig", + "postcss.config.js": "postcssconfig", + "postcss.config.cjs": "postcssconfig", + "postcss.config.mjs": "postcssconfig", + "postcss.config.json": "postcssconfig", + "androidmanifest.xml": "android", + "AndroidManifest.xml": "android", + "build.gradle": "gradle", + "build.gradle.kts": "gradle", + "settings.gradle": "gradle", + "settings.gradle.kts": "gradle", + "gradle.properties": "gradle", + "pom.xml": "java", + "mix.exs": "elixir", + "pubspec.yaml": "dartlang", + "pubspec.yml": "dartlang", + "pyproject.toml": "python", + "requirements.txt": "python", + Pipfile: "python", + "Pipfile.lock": "python", + "poetry.lock": "python", + Procfile: "text", + Jenkinsfile: "groovy", + Vagrantfile: "ruby", + "gulpfile.js": "javascript", + "Gruntfile.js": "javascript", + "vite.config.js": "javascript", + "vite.config.ts": "javascript", + "svelte.config.js": "svelte", + "astro.config.mjs": "astro", + "next.config.js": "javascript", + "nuxt.config.js": "javascript", + "vercel.json": "json", + "netlify.toml": "toml", + "wrangler.toml": "toml", + "firebase.json": "json", + ".firebaserc": "json", + "robots.txt": "text", + "sitemap.xml": "xml", + ".env": "ini", + ".env.local": "ini", + ".env.development": "ini", + ".env.production": "ini", + ".env.test": "ini", + }; + + assignConfigVariants(fileNames, ".eslintrc", "eslint"); + assignConfigVariants(fileNames, ".prettierrc", "prettier"); + assignConfigVariants(fileNames, ".postcssrc", "postcssconfig"); + assignConfigVariants(fileNames, ".babelrc", "babel", [ + "json", + "js", + "cjs", + "mjs", + ]); + + return fileNames; +} + +function createFileExtensions() { + const fileExtensions: Record = { + "js.map": "jsmap", + "css.map": "cssmap", + "test.js": "testjs", + "spec.js": "testjs", + "test.jsx": "testjs", + "spec.jsx": "testjs", + "test.ts": "testts", + "spec.ts": "testts", + "test.tsx": "testts", + "spec.tsx": "testts", + "d.ts": "typescriptdef", + cljs: "clojurescript", + hh: "cppheader", + hpp: "cppheader", + hxx: "cppheader", + apk: "android", + aab: "android", + slim: "android", + mp3: "audio", + wav: "audio", + ogg: "audio", + flac: "audio", + aac: "audio", + mp4: "video", + m4a: "video", + mov: "video", + "3gp": "video", + wmv: "video", + flv: "video", + avi: "video", + webm: "video", + mkv: "video", + png: "image", + jpg: "image", + jpeg: "image", + gif: "image", + bmp: "image", + ico: "image", + webp: "image", + avif: "image", + svg: "svg", + zip: "compressed", + rar: "compressed", + "7z": "compressed", + tar: "compressed", + gz: "compressed", + gzip: "compressed", + tgz: "compressed", + "tar.gz": "compressed", + dmg: "compressed", + iso: "compressed", + bz2: "compressed", + xz: "compressed", + }; + + return fileExtensions; +} + +export function createBuiltinTheme() { + return { + id: BUILTIN_THEME_ID, + label: "Acode", + schemaVersion: SCHEMA_VERSION, + fileNames: createFileNames(), + fileExtensions: createFileExtensions(), + defaults: { + file: "file", + folder: "folder", + folderExpanded: "folder", + rootFolder: "folder", + rootFolderExpanded: "folder", + }, + }; +} diff --git a/src/lib/openFolder.js b/src/lib/openFolder.js index 927606a926..fa300d0ce0 100644 --- a/src/lib/openFolder.js +++ b/src/lib/openFolder.js @@ -10,6 +10,7 @@ import confirm from "dialogs/confirm"; import prompt from "dialogs/prompt"; import select from "dialogs/select"; import escapeStringRegexp from "escape-string-regexp"; +import fileIcons from "lib/fileIcons"; import copyEntry from "utils/copyEntry"; import helpers from "utils/helpers"; import Path from "utils/Path"; @@ -142,8 +143,12 @@ function openFolder(_path, opts = {}) { const $root = collapsableList(title, "folder", { allCaps: true, - ontoggle: () => expandList($root), + ontoggle: () => { + setFolderLeadIcon($root, title, { isRoot: true }); + expandList($root); + }, }); + setFolderLeadIcon($root, title, { isRoot: true }); const $text = $root.$title.get(":scope>span.text"); $root.id = "r" + _path.hashCode(); @@ -1157,16 +1162,38 @@ async function refreshRenamedEntryInOpenFolders( */ function createFolderTile(name, url) { const $list = collapsableList(name, "folder", { - ontoggle: () => expandList($list), + ontoggle: () => { + setFolderLeadIcon($list, name); + expandList($list); + }, }); const { $title } = $list; $title.dataset.url = url; $title.dataset.name = name; $title.dataset.type = "dir"; + setFolderLeadIcon($list, name); return $list; } +function setFolderLeadIcon($list, name, options = {}) { + const $icon = $list.$title?.firstElementChild; + if (!$icon) return; + $icon.className = helpers.getIconForFolder(name, { + expanded: $list.unclasped, + isRoot: options.isRoot, + }); +} + +fileIcons.onChange(() => { + for (const folder of addedFolder) { + const $list = folder.$node; + const name = $list?.$title?.dataset?.name; + if (!$list || !name) continue; + setFolderLeadIcon($list, name, { isRoot: true }); + } +}); + /** * Create a file tile * @param {string} name diff --git a/src/lib/settings.js b/src/lib/settings.js index 8827faadbc..b34eae0f61 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -218,6 +218,7 @@ class Settings { shiftClickSelection: true, showShareButton: true, appIcon: "default", + iconTheme: "builtin", }; this.value = structuredClone(this.#defaultSettings); } diff --git a/src/main.js b/src/main.js index 87b6f9a445..3f412ba423 100644 --- a/src/main.js +++ b/src/main.js @@ -44,6 +44,7 @@ import { canSaveFile } from "lib/commands"; import config from "lib/config"; import EditorFile from "lib/editorFile"; import EditorManager from "lib/editorManager"; +import fileIcons from "lib/fileIcons"; import { initFileList } from "lib/fileList"; import fonts from "lib/fonts"; import lang from "lib/lang"; @@ -311,6 +312,8 @@ async function onDeviceReady() { acode.setLoadingMessage("Loading settings..."); await settings.init(); + fileIcons.bindSettings(settings); + fileIcons.syncFromSettings(); themes.init(); initHighlighting(); @@ -358,6 +361,7 @@ async function onDeviceReady() { // load plugins try { await loadPlugins(); + fileIcons.refreshRenderedIcons(); // Ensure at least one sidebar app is active after all plugins are loaded // This handles cases where the stored section was from an uninstalled plugin sidebarApps.ensureActiveApp(); diff --git a/src/settings/appSettings.js b/src/settings/appSettings.js index a7f912f74c..f62463cacb 100644 --- a/src/settings/appSettings.js +++ b/src/settings/appSettings.js @@ -7,6 +7,7 @@ import select from "dialogs/select"; import actions from "handlers/quickTools"; import actionStack from "lib/actionStack"; import config from "lib/config"; +import fileIcons from "lib/fileIcons"; import fonts from "lib/fonts"; import lang from "lib/lang"; import openFile from "lib/openFile"; @@ -226,6 +227,29 @@ export default function otherSettings() { category: categories.fonts, chevron: true, }, + { + key: "iconTheme", + text: strings["icon theme"] || "Icon theme", + value: values.iconTheme || "builtin", + get select() { + return fileIcons + .list() + .map((theme) => [ + theme.id, + theme.available === false + ? `${theme.label} (${strings.unavailable || "unavailable"})` + : theme.label, + ]); + }, + valueText: (value) => { + const theme = fileIcons.list().find((entry) => entry.id === value); + return theme?.label || value || "Acode"; + }, + info: + strings["settings-info-icon-theme"] || + "Choose how files and folders are shown in the explorer and file lists. Plugin icon themes become available after they load.", + category: categories.interface, + }, { key: "rememberFiles", text: strings["remember opened files"], diff --git a/src/utils/helpers.js b/src/utils/helpers.js index d2b97f5328..ea06211d7a 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -1,9 +1,9 @@ import fsOperation from "fileSystem"; -import { getModeForPath as getCMModeForPath } from "cm/modelist"; import alert from "dialogs/alert"; import escapeStringRegexp from "escape-string-regexp"; import adRewards from "lib/adRewards"; import config from "lib/config"; +import fileIcons from "lib/fileIcons"; import { interstitialAd, requestBannerForPage } from "lib/startAd"; import { isBinaryFile } from "./binaryExtensions"; import { isPlayStoreInstall } from "./installSource"; @@ -11,49 +11,6 @@ import path from "./Path"; import Uri from "./Uri"; import Url from "./Url"; -/** - * Gets programming language name according to filename - * @param {String} filename - * @returns - */ -function getFileType(filename) { - const regex = { - babel: /\.babelrc$/i, - jsmap: /\.js\.map$/i, - yarn: /^yarn\.lock$/i, - testjs: /\.test\.js$/i, - testts: /\.test\.ts$/i, - cssmap: /\.css\.map$/i, - typescriptdef: /\.d\.ts$/i, - clojurescript: /\.cljs$/i, - cppheader: /\.(hh|hpp)$/i, - jsconfig: /^jsconfig.json$/i, - tsconfig: /^tsconfig.json$/i, - android: /\.(apk|aab|slim)$/i, - jsbeautify: /^\.jsbeautifyrc$/i, - webpack: /^webpack\.config\.js$/i, - audio: /\.(mp3|wav|ogg|flac|aac)$/i, - git: /(^\.gitignore$)|(^\.gitmodules$)/i, - video: /\.(mp4|m4a|mov|3gp|wmv|flv|avi)$/i, - image: /\.(png|jpg|jpeg|gif|bmp|ico|webp)$/i, - npm: /(^package\.json$)|(^package\-lock\.json$)/i, - compressed: /\.(zip|rar|7z|tar|gz|gzip|dmg|iso)$/i, - eslint: - /(^\.eslintrc(\.(json5?|ya?ml|toml))?$|eslint\.config\.(c?js|json)$)/i, - postcssconfig: - /(^\.postcssrc(\.(json5?|ya?ml|toml))?$|postcss\.config\.(c?js|json)$)/i, - prettier: - /(^\.prettierrc(\.(json5?|ya?ml|toml))?$|prettier\.config\.(c?js|json)$)/i, - }; - - const fileType = Object.keys(regex).find((type) => - regex[type].test(filename), - ); - if (fileType) return fileType; - - return Url.extname(filename).substring(1); -} - export default { /** * @deprecated This method is deprecated, use 'encodings.decode' instead. @@ -77,20 +34,20 @@ export default { * @param {string} filename */ getIconForFile(filename) { - const type = getFileType(filename); - // Use CodeMirror's modelist to determine mode name - let modeName = "text"; - try { - const mode = getCMModeForPath?.(filename); - modeName = mode?.name || modeName; - } catch (e) { - // fallback to default if CodeMirror modelist isn't available yet - } - - const iconForMode = `file_type_${modeName}`; - const iconForType = `file_type_${type}`; - - return `file file_type_default ${iconForMode} ${iconForType}`; + return fileIcons.getIconClass({ kind: "file", name: filename }); + }, + /** + * Gets icon according to folder name and expansion state + * @param {string} name + * @param {{expanded?: boolean, isRoot?: boolean}} [options] + */ + getIconForFolder(name, options = {}) { + return fileIcons.getIconClass({ + kind: "folder", + name, + expanded: options.expanded, + isRoot: options.isRoot, + }); }, /** * @@ -123,7 +80,9 @@ export default { } } if (item.isDirectory) { - item.icon = "folder"; + item.icon = this.getIconForFolder(item.name, { + isRoot: item.isRoot, + }); } else { if (mode === "folder") { item.disabled = true; diff --git a/tests/unit/fileIconTheme.test.ts b/tests/unit/fileIconTheme.test.ts new file mode 100644 index 0000000000..e0bba3cd98 --- /dev/null +++ b/tests/unit/fileIconTheme.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fileIcons, { BUILTIN_THEME_ID } from "lib/fileIcons"; + +afterEach(() => { + fileIcons.resetForTests(); +}); + +function classOf(resource: Parameters[0]) { + return fileIcons.icon(resource); +} + +describe("built-in file icon matching", () => { + it("prefers exact filenames over extensions", () => { + const handle = fileIcons.resolve("package.json"); + expect(handle.source).toBe("fileName"); + expect(handle.iconId).toBe("npm"); + expect(handle.className).toContain("file_type_npm"); + }); + + it("matches gitignore as a filename, not an extension", () => { + const handle = fileIcons.resolve(".gitignore"); + expect(handle.source).toBe("fileName"); + expect(handle.iconId).toBe("git"); + expect(handle.className).toContain("file_type_git"); + }); + + it("uses the longest compound extension", () => { + expect(fileIcons.resolve("button.test.ts").iconId).toBe("testts"); + expect(fileIcons.resolve("index.d.ts").iconId).toBe("typescriptdef"); + expect(fileIcons.resolve("bundle.js.map").iconId).toBe("jsmap"); + expect(fileIcons.resolve("app.ts").iconId).toBe("ts"); + }); + + it("matches extensions case-insensitively", () => { + expect(fileIcons.resolve("Photo.PNG").iconId).toBe("image"); + expect(fileIcons.resolve("ARCHIVE.TAR.GZ").iconId).toBe("compressed"); + }); + + it("keeps file-type classes compatible with the existing icon font", () => { + expect(classOf("package.json")).toContain("file_type_npm"); + expect(classOf("webpack.config.js")).toContain("file_type_webpack"); + expect(classOf("notes.txt")).toContain("file_type_txt"); + }); +}); + +describe("built-in folder icons", () => { + it("uses the same expandable folder glyph for every folder", () => { + expect(classOf({ kind: "folder", name: "src" })).toBe("icon folder"); + expect(classOf({ kind: "folder", name: "node_modules" })).toBe( + "icon folder", + ); + expect(classOf({ kind: "folder", name: "random-dir" })).toBe("icon folder"); + }); +}); + +describe("plugin icon themes", () => { + it("keeps the built-in theme active until a preferred plugin theme registers", () => { + fileIcons.use("material-icons", { persist: false }); + expect(fileIcons.active()).toMatchObject({ + id: BUILTIN_THEME_ID, + preferredId: "material-icons", + available: false, + }); + expect(fileIcons.resolve("app.js").themeId).toBe(BUILTIN_THEME_ID); + + fileIcons.register({ + id: "material-icons", + name: "Material Icons", + pluginId: "acode.material.icons", + icons: { + js: { className: "icon material-js" }, + }, + fileExtensions: { js: "js" }, + file: "js", + folder: "js", + }); + + expect(fileIcons.active().id).toBe("material-icons"); + expect(fileIcons.resolve("app.js")).toMatchObject({ + className: "icon material-js", + iconId: "js", + source: "fileExtension", + themeId: "material-icons", + }); + }); + + it("does not apply inactive plugin themes", () => { + fileIcons.register({ + id: "other-icons", + name: "Other", + icons: { + js: { className: "icon other-js" }, + }, + fileExtensions: { js: "js" }, + }); + + expect(fileIcons.active().id).toBe(BUILTIN_THEME_ID); + expect(fileIcons.icon("app.js")).not.toContain("other-js"); + }); + + it("resolves SVG packs from an icons folder like VS Code iconPath", () => { + fileIcons.register({ + id: "pack", + name: "Pack", + icons: "https://example.com/icons/", + fileExtensions: { js: "javascript" }, + folderNames: { src: "folder-src" }, + folder: "folder", + folderExpanded: "folder-open", + }); + fileIcons.use("pack", { persist: false }); + + expect(fileIcons.icon("app.js")).toContain("file-icon--pack--javascript"); + expect(fileIcons.icon({ kind: "folder", name: "src" })).toContain( + "file-icon--pack--folder-src", + ); + expect( + fileIcons.icon({ kind: "folder", name: "other", expanded: true }), + ).toContain("file-icon--pack--folder-open"); + }); + + it("falls back to the built-in theme when the active plugin unregisters", () => { + const registration = fileIcons.register({ + id: "temp-icons", + name: "Temp", + pluginId: "plugin.temp", + icons: { + file: { className: "icon temp-file" }, + }, + file: "file", + }); + fileIcons.use("temp-icons", { persist: false }); + expect(fileIcons.icon("unknown.xyz")).toBe("icon temp-file"); + + registration.dispose(); + expect(fileIcons.active().id).toBe(BUILTIN_THEME_ID); + expect(fileIcons.resolve("unknown.xyz").themeId).toBe(BUILTIN_THEME_ID); + }); + + it("unregisters themes owned by a plugin", () => { + fileIcons.register({ + id: "owned-icons", + name: "Owned", + pluginId: "plugin.owned", + icons: { file: { className: "icon owned" } }, + file: "file", + }); + fileIcons.use("owned-icons", { persist: false }); + fileIcons.unregisterByPlugin("plugin.owned"); + expect( + fileIcons.list().find((theme) => theme.id === "owned-icons")?.available, + ).not.toBe(true); + expect(fileIcons.active()).toMatchObject({ + id: BUILTIN_THEME_ID, + preferredId: "owned-icons", + available: false, + }); + }); + + it("resolves batches in input order", () => { + const handles = fileIcons.resolveMany([ + "package.json", + { kind: "folder", name: "src" }, + "main.py", + ]); + expect(handles.map((handle) => handle.iconId)).toEqual([ + "npm", + "folder", + "py", + ]); + }); + + it("lets user overrides win over theme associations", () => { + fileIcons.setOverride({ + kind: "file", + name: "package.json", + icon: "webpack", + }); + expect(fileIcons.resolve("package.json").iconId).toBe("webpack"); + }); + + it("rejects invalid themes without replacing a previous valid version", () => { + fileIcons.register({ + id: "stable-icons", + name: "Stable", + icons: { js: { className: "icon stable-js" } }, + fileExtensions: { js: "js" }, + }); + fileIcons.use("stable-icons", { persist: false }); + + expect(() => + fileIcons.update("stable-icons", { + id: "stable-icons", + fileExtensions: { js: "js" }, + icons: { js: { src: "javascript:alert(1)" } }, + }), + ).toThrow(/Unsafe/); + + expect(fileIcons.icon("app.js")).toBe("icon stable-js"); + }); + + it("lets later associations win when keys collide", () => { + fileIcons.register({ + id: "dup-icons", + fileExtensions: { js: "js", JS: "javascript" }, + icons: { + js: { className: "a" }, + javascript: { className: "b" }, + }, + }); + fileIcons.use("dup-icons", { persist: false }); + expect(fileIcons.resolve("app.js").iconId).toBe("javascript"); + }); +}); From 29f2f118e8a2cfb6de74f97e3d7195ef2d1aa533 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:45:52 +0530 Subject: [PATCH 2/9] feat: improve the api and its use in other part of app --- src/components/fileTree/index.js | 28 - src/components/referencesPanel/utils.js | 6 +- src/components/settingsPage.js | 12 +- src/dialogs/select.js | 16 +- src/lang/en-us.json | 4 +- src/lib/acode.js | 4 +- src/lib/fileIcons.ts | 723 +++++++----------- src/lib/fileIconsBuiltin.ts | 14 +- src/lib/recents.js | 4 +- src/settings/appSettings.js | 12 +- src/settings/formatterSettings.js | 3 +- src/sidebarApps/files/index.js | 4 + src/sidebarApps/searchInFiles/cmResultView.js | 11 +- src/utils/helpers.js | 4 +- tests/unit/fileIconAssets.test.ts | 120 +++ tests/unit/fileIconExample.test.ts | 49 ++ tests/unit/fileIconSidebar.test.ts | 138 ++++ tests/unit/fileIconTheme.test.ts | 149 +++- 18 files changed, 763 insertions(+), 538 deletions(-) create mode 100644 tests/unit/fileIconAssets.test.ts create mode 100644 tests/unit/fileIconExample.test.ts create mode 100644 tests/unit/fileIconSidebar.test.ts diff --git a/src/components/fileTree/index.js b/src/components/fileTree/index.js index abac307981..3c0f5f9d8b 100644 --- a/src/components/fileTree/index.js +++ b/src/components/fileTree/index.js @@ -2,7 +2,6 @@ import "./style.scss"; import tile from "components/tile"; import VirtualList from "components/virtualList"; import tag from "html-tag-js"; -import fileIcons from "lib/fileIcons"; import helpers from "utils/helpers"; import Path from "utils/Path"; @@ -36,7 +35,6 @@ export default class FileTree { this.isLoading = false; this.childTrees = new Map(); // Track child trees for cleanup this.depth = options._depth || 0; // Internal: nesting depth - this._offIcons = fileIcons.onChange(() => this.applyIcons()); } /** @@ -330,36 +328,10 @@ export default class FileTree { * Destroy the file tree and cleanup */ destroy() { - this._offIcons?.(); - this._offIcons = null; this.clear(); this.container.classList.remove("file-tree"); } - applyIcons() { - for (const $file of this.container.querySelectorAll( - ':scope > [data-type="file"][data-name]', - )) { - const $icon = $file.querySelector(":scope > span:first-child"); - if ($icon) { - $icon.className = helpers.getIconForFile($file.dataset.name); - } - } - - for (const $folder of this.container.querySelectorAll( - '[data-type="dir"][data-name]', - )) { - const $icon = $folder.querySelector(":scope > span:first-child"); - if (!$icon) continue; - const expanded = !$folder - .closest(".collapsible") - ?.classList.contains("hidden"); - $icon.className = helpers.getIconForFolder($folder.dataset.name, { - expanded, - }); - } - } - /** * Find an entry element by URL * @param {string} url diff --git a/src/components/referencesPanel/utils.js b/src/components/referencesPanel/utils.js index d02de6ca54..a7cec957c1 100644 --- a/src/components/referencesPanel/utils.js +++ b/src/components/referencesPanel/utils.js @@ -87,7 +87,11 @@ export function createReferenceItem(item, options = {}) { onclick={() => onToggleFile?.(item.uri)} > - + {sanitize(item.fileName)} {item.count} diff --git a/src/components/settingsPage.js b/src/components/settingsPage.js index 25e2ce8aeb..3357206c6e 100644 --- a/src/components/settingsPage.js +++ b/src/components/settingsPage.js @@ -1,3 +1,4 @@ +import fileIcons from "lib/fileIcons"; import "./settingsPage.scss"; import colorPicker from "dialogs/color"; import prompt from "dialogs/prompt"; @@ -390,12 +391,19 @@ function createListItemElement(item, options, useInfoAsDescription) { const $item = (
{item.image && ( diff --git a/src/dialogs/select.js b/src/dialogs/select.js index 8aeb1c6d67..938288d6e5 100644 --- a/src/dialogs/select.js +++ b/src/dialogs/select.js @@ -3,6 +3,7 @@ import tile from "components/tile"; import DOMPurify from "dompurify"; import actionStack from "lib/actionStack"; import restoreTheme from "lib/restoreTheme"; +import fileIcons from "lib/fileIcons"; /** * @typedef {object} SelectOptions @@ -20,6 +21,7 @@ import restoreTheme from "lib/restoreTheme"; * @property {string} [text] * @property {string} [subText] * @property {string} [icon] + * @property {{name: string, kind?: "file" | "folder"}} [fileIcon] * @property {string} [className] * @property {string} [title] * @property {boolean} [disabled] @@ -100,8 +102,18 @@ function select(title, items, options = {}) { itemOptions.text = item; } - // handle icon (lead) - if (itemOptions.icon) { + // File resources stay refreshable while image assets load. + if (itemOptions.fileIcon) { + const resource = itemOptions.fileIcon; + lead = ( + + ); + } else if (itemOptions.icon) { if (itemOptions.icon === "letters" && !!itemOptions.letters) { lead = ( diff --git a/src/lang/en-us.json b/src/lang/en-us.json index da0d83982c..d47a7b1ec0 100644 --- a/src/lang/en-us.json +++ b/src/lang/en-us.json @@ -174,8 +174,8 @@ "light": "Light", "dark": "Dark", "file browser": "File Browser", - "icon theme": "Icon theme", - "settings-info-icon-theme": "Choose how files and folders are shown in the explorer and file lists. Plugin icon themes become available after they load.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose how files and folders are shown in the explorer and file lists. Plugin icon packs become available after they load.", "unavailable": "unavailable", "operation not permitted": "Operation not permitted", "no such file or directory": "No such file or directory", diff --git a/src/lib/acode.js b/src/lib/acode.js index 73c88e2e6b..eb60bec5d0 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -51,7 +51,7 @@ import windowResize from "handlers/windowResize"; import actionStack from "lib/actionStack"; import commands from "lib/commands"; import EditorFile from "lib/editorFile"; -import fileIcons from "lib/fileIcons"; +import fileIcons, { fileIconApi } from "lib/fileIcons"; import fileIndex from "lib/fileIndex"; import files from "lib/fileList"; import fileTypeHandler from "lib/fileTypeHandler"; @@ -404,7 +404,7 @@ class Acode { deprecatedFileList.replacement = "fileIndex"; this.define("fileList", deprecatedFileList); this.define("fileIndex", fileIndex); - this.define("fileIcons", fileIcons); + this.define("fileIcons", fileIconApi); this.define("fs", fsOperation); this.define("confirm", confirm); this.define("helpers", helpers); diff --git a/src/lib/fileIcons.ts b/src/lib/fileIcons.ts index a9177b2d09..66e14088f9 100644 --- a/src/lib/fileIcons.ts +++ b/src/lib/fileIcons.ts @@ -10,23 +10,15 @@ const UNSAFE_SRC_RE = /[\s"'()\\]/; export type IconKind = "file" | "folder"; export type IconMatchSource = - | "override" | "fileName" | "fileExtension" | "languageId" | "folderName" | "default"; -export interface IconDefinition { - className?: string; - expandedClassName?: string; - src?: string; - expandedSrc?: string; - light?: string; - dark?: string; - monochrome?: boolean; - iconPath?: string; -} +export type IconDefinition = + | { src: string; monochrome?: boolean; className?: never } + | { className: string; src?: never; monochrome?: never }; export interface IconAssociations { fileNames?: Record; @@ -48,14 +40,9 @@ export interface IconDefaults { export interface FileIconTheme extends IconAssociations, IconDefaults { id: string; name?: string; - label?: string; schemaVersion?: number; - pluginId?: string; - baseUrl?: string; - icons?: string | Record; - iconDefinitions?: Record; - associations?: IconAssociations; - defaults?: IconDefaults; + pluginId: string; + icons?: string | Record; } export interface IconResource { @@ -64,7 +51,6 @@ export interface IconResource { languageId?: string; expanded?: boolean; isRoot?: boolean; - appearance?: "dark" | "light"; } export interface IconHandle { @@ -78,7 +64,7 @@ export interface IconHandle { export interface IconThemeInfo { id: string; - label: string; + name: string; available: boolean; pluginId: string | null; } @@ -86,13 +72,13 @@ export interface IconThemeInfo { export interface ActiveIconTheme { id: string; preferredId: string; - label: string; + name: string; available: boolean; } interface CompiledTheme { id: string; - label: string; + name: string; pluginId: string | null; schemaVersion: number; icons: Map; @@ -105,25 +91,12 @@ interface CompiledTheme { defaults: Required; } -interface RegisterOptions { - builtin?: boolean; - pluginId?: string; - silent?: boolean; -} - interface IconThemeSettings { value?: { iconTheme?: string }; on?: (event: string, callback: (value: unknown) => void) => void; update?: (showToast?: boolean) => void; } -interface OverrideRule { - kind?: IconKind; - name: string; - icon: string; - caseSensitive?: boolean; -} - type NormalizedResource = IconResource & { kind: IconKind; name: string }; function basename(value: unknown): string { @@ -168,22 +141,6 @@ function joinUrl(base: string, path: string): string { return `${String(base).replace(/\/?$/, "/")}${rel.replace(/^\//, "")}`; } -function resolveAssetPath( - path: string, - iconsDir: string | null, - baseUrl?: string, -): string { - const value = path.trim(); - if (!value) return ""; - if ( - /^(https?:|data:|blob:|file:|content:)/i.test(value) || - value.startsWith("/") - ) { - return value; - } - return joinUrl(iconsDir || baseUrl || "", value); -} - function getDocument(): Document | null { return typeof document !== "undefined" ? document : null; } @@ -209,152 +166,83 @@ export function buildBuiltinFolderClass(_folderId?: string): string { return "icon folder"; } -function iconIdFromAssoc(value: unknown): string { - if (typeof value === "string") return value; - if ( - value && - typeof value === "object" && - "icon" in value && - typeof value.icon === "string" - ) { - return value.icon; +const ASSOCIATION_FIELDS = [ + "fileNames", + "fileExtensions", + "languageIds", + "folderNames", + "folderNamesExpanded", +] as const; +const DEFAULT_FIELDS = [ + "file", + "folder", + "folderExpanded", + "rootFolder", + "rootFolderExpanded", +] as const; +const THEME_FIELDS = new Set([ + "id", + "name", + "schemaVersion", + "pluginId", + "icons", + ...ASSOCIATION_FIELDS, + ...DEFAULT_FIELDS, +]); + +function assertFields(value: object, allowed: Set, path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${path}.${key} is not supported`); } - throw new Error("Association values must be icon ids"); -} - -function collectIconIds(theme: FileIconTheme): Set { - const ids = new Set(); - const add = (value: unknown) => { - if (typeof value === "string" && value) ids.add(value); - }; - const addMap = (map?: Record) => { - if (!map) return; - for (const value of Object.values(map)) add(value); - }; - addMap(theme.fileNames); - addMap(theme.fileExtensions); - addMap(theme.languageIds); - addMap(theme.folderNames); - addMap(theme.folderNamesExpanded); - addMap(theme.associations?.fileNames); - addMap(theme.associations?.fileExtensions); - addMap(theme.associations?.languageIds); - addMap(theme.associations?.folderNames); - addMap(theme.associations?.folderNamesExpanded); - add(theme.file); - add(theme.folder); - add(theme.folderExpanded); - add(theme.rootFolder); - add(theme.rootFolderExpanded); - add(theme.defaults?.file); - add(theme.defaults?.folder); - add(theme.defaults?.folderExpanded); - add(theme.defaults?.rootFolder); - add(theme.defaults?.rootFolderExpanded); - return ids; -} - -function folderIconIds(theme: FileIconTheme): Set { - const ids = new Set(); - const add = (value?: string) => { - if (value) ids.add(value); - }; - const addMap = (map?: Record) => { - if (!map) return; - for (const value of Object.values(map)) add(value); - }; - addMap(theme.folderNames); - addMap(theme.folderNamesExpanded); - addMap(theme.associations?.folderNames); - addMap(theme.associations?.folderNamesExpanded); - add(theme.folder); - add(theme.folderExpanded); - add(theme.rootFolder); - add(theme.rootFolderExpanded); - add(theme.defaults?.folder); - add(theme.defaults?.folderExpanded); - add(theme.defaults?.rootFolder); - add(theme.defaults?.rootFolderExpanded); - return ids; } -function fromVsCodeIcon( - def: IconDefinition | string, - iconsDir: string | null, - baseUrl?: string, -): IconDefinition | string { - if (typeof def === "string") { - if (isSafeSrc(def)) return resolveAssetPath(def, iconsDir, baseUrl); - return def; - } - const iconPath = def.iconPath || def.src; - if (!iconPath) return def; - const next: IconDefinition = { - src: resolveAssetPath(iconPath, iconsDir, baseUrl), - }; - if (def.expandedSrc) { - next.expandedSrc = resolveAssetPath(def.expandedSrc, iconsDir, baseUrl); +function iconIdFromAssoc(value: unknown): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error("Association values must be non-empty icon ids"); } - if (def.light) next.light = resolveAssetPath(def.light, iconsDir, baseUrl); - if (def.dark) next.dark = resolveAssetPath(def.dark, iconsDir, baseUrl); - if (def.className) next.className = def.className; - if (def.expandedClassName) next.expandedClassName = def.expandedClassName; - if (def.monochrome) next.monochrome = true; - return next; + return value; } -export function prepareTheme(input: FileIconTheme): FileIconTheme { - const theme: FileIconTheme = { ...input }; - if (typeof theme.name === "string" && !theme.label) { - theme.label = theme.name; - } - - let iconsDir: string | null = null; - if (typeof theme.icons === "string") { - iconsDir = theme.icons.replace(/\/?$/, "/"); - theme.icons = {}; - } else if ( - theme.icons && - typeof theme.icons === "object" && - !Array.isArray(theme.icons) - ) { - theme.icons = { ...theme.icons }; - } else { - theme.icons = {}; - } - - if (typeof theme.baseUrl === "string" && !iconsDir) { - iconsDir = joinUrl(theme.baseUrl, "icons/"); - } +type ThemeInput = Omit & { pluginId?: string }; - const icons = theme.icons; - if (theme.iconDefinitions) { - for (const [id, def] of Object.entries(theme.iconDefinitions)) { - icons[id] = fromVsCodeIcon(def, iconsDir, theme.baseUrl); - } - } - - const folders = folderIconIds(theme); - for (const id of collectIconIds(theme)) { - if (icons[id] || !iconsDir) continue; - const def: IconDefinition = { src: joinUrl(iconsDir, `${id}.svg`) }; - if ( - !id.endsWith("-open") && - (id.startsWith("folder") || folders.has(id)) - ) { - def.expandedSrc = joinUrl(iconsDir, `${id}-open.svg`); +function prepareTheme(input: ThemeInput): ThemeInput { + assertFields(input, THEME_FIELDS, "theme"); + if (typeof input.icons !== "string") return input; + const icons: Record = Object.create(null); + const add = (value: unknown) => { + const id = iconIdFromAssoc(value); + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + throw new Error( + `Icon id '${id}' must use letters, digits, underscores or hyphens with an icons directory`, + ); } - icons[id] = def; + icons[id] = { src: joinUrl(input.icons as string, `${id}.svg`) }; + }; + for (const field of ASSOCIATION_FIELDS) { + if (input[field]) + for (const value of Object.values(input[field]!)) add(value); } - - return theme; + for (const field of DEFAULT_FIELDS) if (input[field]) add(input[field]); + return { ...input, icons }; } function normalizeAssocKey(key: string, kind: string): string { - const value = String(key ?? "").trim(); + const value = String(key ?? ""); if (!value) throw new Error(`Empty ${kind} association`); - if (kind === "fileExtension") return value.replace(/^\./, "").toLowerCase(); - if (kind === "languageId" || kind === "folderName") return value.toLowerCase(); + if (kind === "fileExtension") { + if ( + value.startsWith(".") || + value.endsWith(".") || + value.includes("..") || + /[\s/\\]/.test(value) + ) + throw new Error( + `Invalid fileExtension '${key}'; omit leading dots and paths`, + ); + return value.toLowerCase(); + } + if (kind === "languageId" || kind === "folderName") + return value.toLowerCase(); return value; } @@ -375,13 +263,20 @@ function addAssociations( rawKey, kind === "fileExtension" ? "fileExtension" : "fileName", ); - map.set(key, iconIdFromAssoc(rawValue)); + const iconId = iconIdFromAssoc(rawValue); + if (map.has(key) && map.get(key) !== iconId) { + throw new Error( + `Conflicting ${kind} association '${rawKey}' (normalized to '${key}')`, + ); + } + map.set(key, iconId); } } -function assetClassName(themeId: string, iconId: string, variant = ""): string { - const suffix = variant ? `-${variant}` : ""; - return `file-icon--${sanitizeClassToken(themeId)}--${sanitizeClassToken(iconId)}${suffix}`; +function assetClassName(themeId: string, iconId: string): string { + const encode = (value: string) => + Array.from(value, (char) => char.codePointAt(0)!.toString(16)).join("_"); + return `file-icon--${encode(themeId)}--${encode(iconId)}`; } function cssForSrc( @@ -397,41 +292,35 @@ function cssForSrc( } function normalizeIconDef(id: string, def: unknown): IconDefinition { - if (typeof def === "string") { - return isSafeSrc(def) ? { src: def.trim() } : { className: def }; - } if (!def || typeof def !== "object" || Array.isArray(def)) { - throw new Error(`Invalid icon definition '${id}'`); + throw new Error(`icons.${id} must be an object with src or className`); } + assertFields(def, new Set(["src", "className", "monochrome"]), `icons.${id}`); const rec = def as IconDefinition; - const normalized: IconDefinition = {}; - if (typeof rec.className === "string" && rec.className.trim()) { - normalized.className = rec.className.trim(); - } - if (typeof rec.expandedClassName === "string" && rec.expandedClassName.trim()) { - normalized.expandedClassName = rec.expandedClassName.trim(); - } - for (const key of ["src", "expandedSrc", "light", "dark"] as const) { - const value = rec[key]; - if (value == null) continue; - if (!isSafeSrc(value)) { - throw new Error(`Unsafe icon asset for '${id}.${key}'`); - } - normalized[key] = value.trim(); + if (!!rec.src === !!rec.className) + throw new Error(`icons.${id} needs exactly one of src or className`); + if (rec.src && !isSafeSrc(rec.src)) + throw new Error(`Unsafe icon asset for 'icons.${id}.src'`); + if ( + rec.className && + (typeof rec.className !== "string" || !rec.className.trim()) + ) { + throw new Error(`icons.${id}.className must be a non-empty string`); } - if (rec.monochrome) normalized.monochrome = true; if ( - !normalized.className && - !normalized.src && - !normalized.light && - !normalized.dark + rec.monochrome !== undefined && + (typeof rec.monochrome !== "boolean" || !rec.src) ) { - throw new Error(`Icon '${id}' needs className or src`); + throw new Error( + `icons.${id}.monochrome requires src and must be a boolean`, + ); } - return normalized; + return rec.src + ? { src: rec.src.trim(), monochrome: rec.monochrome } + : { className: rec.className!.trim() }; } -function compileTheme(input: FileIconTheme): CompiledTheme { +function compileTheme(input: ThemeInput): CompiledTheme { if (!input || typeof input !== "object" || Array.isArray(input)) { throw new Error("Icon theme must be an object"); } @@ -452,7 +341,6 @@ function compileTheme(input: FileIconTheme): CompiledTheme { } } - const associations = theme.associations || {}; const fileNames = new Map(); const fileNamesCi = new Map(); const fileExtensions = new Map(); @@ -460,39 +348,55 @@ function compileTheme(input: FileIconTheme): CompiledTheme { const folderNames = new Map(); const folderNamesExpanded = new Map(); - addAssociations(fileNames, theme.fileNames || associations.fileNames, "fileName"); + addAssociations(fileNames, theme.fileNames, "fileName"); for (const [key, iconId] of fileNames) { const lower = key.toLowerCase(); - if (!fileNamesCi.has(lower)) fileNamesCi.set(lower, iconId); - } - addAssociations( - fileExtensions, - theme.fileExtensions || associations.fileExtensions, - "fileExtension", - { caseInsensitive: true }, - ); - addAssociations( - languageIds, - theme.languageIds || associations.languageIds, - "languageId", - { caseInsensitive: true }, - ); - addAssociations( - folderNames, - theme.folderNames || associations.folderNames, - "folderName", - { caseInsensitive: true }, - ); + if (fileNamesCi.has(lower) && fileNamesCi.get(lower) !== iconId) + throw new Error(`Conflicting fileName association '${key}'`); + fileNamesCi.set(lower, iconId); + } + addAssociations(fileExtensions, theme.fileExtensions, "fileExtension", { + caseInsensitive: true, + }); + addAssociations(languageIds, theme.languageIds, "languageId", { + caseInsensitive: true, + }); + addAssociations(folderNames, theme.folderNames, "folderName", { + caseInsensitive: true, + }); addAssociations( folderNamesExpanded, - theme.folderNamesExpanded || associations.folderNamesExpanded, + theme.folderNamesExpanded, "folderName", { caseInsensitive: true }, ); + if (id !== BUILTIN_THEME_ID) { + if (typeof theme.pluginId !== "string" || !theme.pluginId.trim()) + throw new Error("theme.pluginId is required"); + if ( + theme.icons !== undefined && + (!theme.icons || + typeof theme.icons !== "object" || + Array.isArray(theme.icons)) + ) + throw new Error("theme.icons must be a directory URL or definition map"); + for (const field of ASSOCIATION_FIELDS) { + for (const [key, iconId] of Object.entries(theme[field] || {})) { + if (!icons.has(iconId)) + throw new Error( + `${field}.${key} references unknown icon '${iconId}'`, + ); + } + } + for (const field of DEFAULT_FIELDS) { + if (theme[field] !== undefined && !icons.has(theme[field]!)) + throw new Error(`${field} references unknown icon '${theme[field]}'`); + } + } return { id, - label: String(theme.label || theme.name || id), + name: String(theme.name || id), pluginId: theme.pluginId || null, schemaVersion, icons, @@ -503,16 +407,15 @@ function compileTheme(input: FileIconTheme): CompiledTheme { folderNames, folderNamesExpanded, defaults: { - file: theme.defaults?.file || theme.file || "file", - folder: theme.defaults?.folder || theme.folder || "folder", - folderExpanded: - theme.defaults?.folderExpanded || theme.folderExpanded || "folder", - rootFolder: theme.defaults?.rootFolder || theme.rootFolder || "folder", + file: theme.file || "file", + folder: theme.folder || "folder", + folderExpanded: theme.folderExpanded || theme.folder || "folder", + rootFolder: theme.rootFolder || theme.folder || "folder", rootFolderExpanded: - theme.defaults?.rootFolderExpanded || theme.rootFolderExpanded || - theme.defaults?.rootFolder || theme.rootFolder || + theme.folderExpanded || + theme.folder || "folder", }, }; @@ -524,7 +427,7 @@ function matchExtension( ): string | undefined { const lower = name.toLowerCase(); const parts = lower.split("."); - if (parts.length < 2) return undefined; + if (parts.length < 2 || (parts.length === 2 && !parts[0])) return undefined; for (let i = 1; i < parts.length; i++) { const ext = parts.slice(i).join("."); if (ext && extensions.has(ext)) return extensions.get(ext); @@ -541,19 +444,19 @@ function lastExtension(name: string): string { } class FileIconRegistry { - #themes = new Map(); #compiled = new Map(); - #overrides = new Map(); - #listeners = new Set<(info: { activeId: string; preferredId: string }) => void>(); + #listeners = new Set< + (info: { activeId: string; preferredId: string }) => void + >(); #activeId = BUILTIN_THEME_ID; #preferredId = BUILTIN_THEME_ID; + #assetStates = new Map(); + #assetRefreshQueued = false; #settings: IconThemeSettings | null = null; constructor() { - this.#putTheme(createBuiltinTheme() as FileIconTheme, { - builtin: true, - silent: true, - }); + const builtin = compileTheme(createBuiltinTheme()); + this.#compiled.set(builtin.id, builtin); this.#activeId = BUILTIN_THEME_ID; this.#preferredId = BUILTIN_THEME_ID; } @@ -572,69 +475,39 @@ class FileIconRegistry { if (typeof id === "string" && id) this.use(id, { persist: false }); } - /** - * Register or replace an icon theme. If `icons` is a folder URL, referenced - * ids resolve to `/.svg` (and `-open.svg` for folders). - */ - register(theme: FileIconTheme, options: RegisterOptions = {}): { dispose: () => void } { + /** Register a complete theme, replacing only a theme with the same owner. */ + register(theme: FileIconTheme): { dispose: () => void } { const compiled = compileTheme(theme); - if (compiled.id === BUILTIN_THEME_ID && !options.builtin) { + if (compiled.id === BUILTIN_THEME_ID) throw new Error("Cannot replace the built-in icon theme"); + const previous = this.#compiled.get(compiled.id); + if (previous && previous.pluginId !== compiled.pluginId) + throw new Error(`Icon theme '${compiled.id}' belongs to another plugin`); + this.#compiled.set(compiled.id, compiled); + if (compiled.id === this.#preferredId) { + this.#activate(compiled.id); + this.#emitChange(); } - if (this.#themes.has(compiled.id) && compiled.id !== BUILTIN_THEME_ID) { - this.update(compiled.id, theme); - return { dispose: () => this.unregister(compiled.id) }; - } - this.#putTheme(theme, options); - return { dispose: () => this.unregister(compiled.id) }; - } - - /** @deprecated Use register() */ - registerTheme(theme: FileIconTheme, options: RegisterOptions = {}) { - return this.register(theme, options); - } - - update(id: string, theme: FileIconTheme): void { - if (id === BUILTIN_THEME_ID) { - throw new Error("Cannot update the built-in icon theme"); - } - if (!this.#themes.has(id)) { - throw new Error(`Icon theme '${id}' is not registered`); - } - const previous = this.#compiled.get(id); - this.#putTheme( - { - ...theme, - id, - pluginId: theme.pluginId || previous?.pluginId || undefined, + return { + dispose: () => { + if (this.#compiled.get(compiled.id) === compiled) + this.unregister(compiled.id); }, - {}, - ); - } - - /** @deprecated Use update() */ - updateTheme(id: string, theme: FileIconTheme): void { - this.update(id, theme); + }; } unregister(id: string): boolean { if (id === BUILTIN_THEME_ID) return false; - if (!this.#themes.has(id)) return false; - this.#themes.delete(id); + if (!this.#compiled.has(id)) return false; this.#compiled.delete(id); this.#removeThemeStyles(id); if (this.#activeId === id) { - this.#activeId = BUILTIN_THEME_ID; + this.#activate(BUILTIN_THEME_ID); this.#emitChange(); } return true; } - /** @deprecated Use unregister() */ - unregisterTheme(id: string): boolean { - return this.unregister(id); - } - unregisterByPlugin(pluginId: string): void { if (!pluginId) return; for (const [id, compiled] of [...this.#compiled]) { @@ -647,7 +520,7 @@ class FileIconRegistry { for (const compiled of this.#compiled.values()) { list.push({ id: compiled.id, - label: compiled.label, + name: compiled.name, available: true, pluginId: compiled.pluginId, }); @@ -655,7 +528,7 @@ class FileIconRegistry { if (this.#preferredId && !this.#compiled.has(this.#preferredId)) { list.push({ id: this.#preferredId, - label: this.#preferredId, + name: this.#preferredId, available: false, pluginId: null, }); @@ -663,26 +536,16 @@ class FileIconRegistry { return list; } - /** @deprecated Use list() */ - listThemes(): IconThemeInfo[] { - return this.list(); - } - active(): ActiveIconTheme { const compiled = this.#compiled.get(this.#activeId); return { id: this.#activeId, preferredId: this.#preferredId, - label: compiled?.label || this.#activeId, + name: compiled?.name || this.#activeId, available: this.#compiled.has(this.#preferredId), }; } - /** @deprecated Use active() */ - getActiveTheme(): ActiveIconTheme { - return this.active(); - } - use(id: string, options: { persist?: boolean } = {}): ActiveIconTheme { const next = typeof id === "string" && id.trim() ? id.trim() : BUILTIN_THEME_ID; @@ -691,45 +554,12 @@ class FileIconRegistry { this.#preferredId = next; const resolved = this.#compiled.has(next) ? next : BUILTIN_THEME_ID; const activeChanged = resolved !== this.#activeId; - this.#activeId = resolved; + if (activeChanged) this.#activate(resolved); if (persist) this.#persistPreferred(next); if (preferredChanged || activeChanged) this.#emitChange(); return this.active(); } - /** @deprecated Use use() */ - setPreferredTheme(id: string, options: { persist?: boolean } = {}) { - return this.use(id, options); - } - - /** @deprecated Use use() */ - setActiveTheme(id: string) { - return this.use(id); - } - - setOverride(rule: OverrideRule): { dispose: () => void } { - if (!rule || typeof rule.name !== "string" || !rule.name) { - throw new Error("Override name is required"); - } - const kind = rule.kind === "folder" ? "folder" : "file"; - this.#overrides.set(overrideKey(kind, rule.name, rule.caseSensitive !== false), { - icon: String(rule.icon || ""), - kind, - name: rule.name, - caseSensitive: rule.caseSensitive !== false, - }); - this.#emitChange(); - return { dispose: () => this.removeOverride(rule) }; - } - - removeOverride(rule: Pick): boolean { - if (!rule?.name) return false; - const kind = rule.kind === "folder" ? "folder" : "file"; - return this.#overrides.delete( - overrideKey(kind, rule.name, rule.caseSensitive !== false), - ); - } - resolve(resource: IconResource | string): IconHandle { const input = normalizeResource(resource); const compiled = @@ -745,7 +575,7 @@ class FileIconRegistry { themeId: BUILTIN_THEME_ID, }; } - const languageId = input.languageId || inferLanguageId(input.name); + const languageId = input.languageId; if (input.kind === "folder") { return this.#resolveFolder(input, compiled, builtin); } @@ -761,10 +591,6 @@ class FileIconRegistry { return this.resolve(resource).className; } - getIconClass(resource: IconResource | string): string { - return this.icon(resource); - } - onChange( listener: (info: { activeId: string; preferredId: string }) => void, ): () => void { @@ -773,18 +599,17 @@ class FileIconRegistry { return () => this.#listeners.delete(listener); } - onDidChange( - listener: (info: { activeId: string; preferredId: string }) => void, - ): () => void { - return this.onChange(listener); - } - - refreshRenderedIcons(): void { - const doc = getDocument(); - if (!doc) return; + refreshRenderedIcons(root: ParentNode | null = getDocument()): void { + if (!root) return; const apply = () => { - for (const $tile of doc.querySelectorAll( + for (const icon of root.querySelectorAll( + "[data-file-icon-name]", + )) { + icon.className = + `${this.icon({ name: icon.dataset.fileIconName || "", kind: icon.dataset.fileIconKind === "folder" ? "folder" : "file" })} ${icon.dataset.fileIconExtra || ""}`.trim(); + } + for (const $tile of root.querySelectorAll( '[data-type="file"][data-name]', )) { applyLeadClass( @@ -793,7 +618,7 @@ class FileIconRegistry { ); } - for (const $tile of doc.querySelectorAll( + for (const $tile of root.querySelectorAll( '[data-type="dir"][data-name], [data-type="root"][data-name]', )) { const expanded = !$tile @@ -810,42 +635,27 @@ class FileIconRegistry { ); } - this.#refreshEditorTabs(); + if (root === getDocument()) this.#refreshEditorTabs(); }; apply(); - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(apply); - } } resetForTests(): void { - for (const id of [...this.#themes.keys()]) { + for (const id of [...this.#compiled.keys()]) { if (id !== BUILTIN_THEME_ID) this.unregister(id); } - this.#overrides.clear(); this.#listeners.clear(); this.#preferredId = BUILTIN_THEME_ID; this.#activeId = BUILTIN_THEME_ID; this.#settings = null; } - #putTheme(theme: FileIconTheme, options: RegisterOptions): CompiledTheme { - const compiled = compileTheme(theme); - if (options.pluginId && !compiled.pluginId) { - compiled.pluginId = options.pluginId; - } - this.#themes.set(compiled.id, { ...theme, pluginId: compiled.pluginId || undefined }); - this.#compiled.set(compiled.id, compiled); - this.#applyThemeStyles(compiled); - - const becameActive = - compiled.id === this.#preferredId && this.#activeId !== compiled.id; - if (becameActive) this.#activeId = compiled.id; - if (!options.silent && (becameActive || compiled.id === this.#activeId)) { - this.#emitChange(); - } - return compiled; + #activate(id: string): void { + this.#removeThemeStyles(this.#activeId); + this.#assetStates = new Map(); + this.#activeId = id; + this.#applyThemeStyles(this.#compiled.get(id)!); } #persistPreferred(id: string): void { @@ -906,17 +716,6 @@ class FileIconRegistry { languageId?: string, ): IconHandle { const name = input.name; - const override = this.#matchOverride("file", name); - if (override) { - return this.#handleFromIcon( - compiled, - builtin, - override.icon, - "override", - input, - languageId, - ); - } const exact = compiled.fileNames.get(name); if (exact) { @@ -953,6 +752,7 @@ class FileIconRegistry { ); } + languageId ||= inferLanguageId(name); const langKey = languageId ? languageId.toLowerCase() : ""; if (langKey && compiled.languageIds.has(langKey)) { return this.#handleFromIcon( @@ -992,17 +792,6 @@ class FileIconRegistry { compiled: CompiledTheme, builtin: CompiledTheme, ): IconHandle { - const override = this.#matchOverride("folder", input.name); - if (override) { - return this.#handleFromIcon( - compiled, - builtin, - override.icon, - "override", - input, - ); - } - const key = input.name.toLowerCase(); if (input.expanded && compiled.folderNamesExpanded.has(key)) { return this.#handleFromIcon( @@ -1035,13 +824,6 @@ class FileIconRegistry { return this.#handleFromIcon(compiled, builtin, defaultId, "default", input); } - #matchOverride(kind: IconKind, name: string): OverrideRule | undefined { - return ( - this.#overrides.get(overrideKey(kind, name, true)) || - this.#overrides.get(overrideKey(kind, name, false)) - ); - } - #handleFromIcon( compiled: CompiledTheme, builtin: CompiledTheme, @@ -1069,6 +851,18 @@ class FileIconRegistry { }; } + if ( + compiled.id !== BUILTIN_THEME_ID && + input.kind === "folder" && + input.expanded + ) { + return this.#resolveFolder( + { ...input, expanded: false }, + compiled, + builtin, + ); + } + if (compiled.id !== BUILTIN_THEME_ID && source !== "default") { const fallbackId = input.kind === "folder" @@ -1117,52 +911,77 @@ class FileIconRegistry { input: NormalizedResource, languageId?: string, ): string { - if (def) { - if (input.kind === "folder" && input.expanded) { - if (def.expandedClassName) return def.expandedClassName; - if (def.expandedSrc) { - return `icon ${assetClassName(compiled.id, iconId, "expanded")}`; - } - } - if (def.className) return def.className; - if (def.src || def.light || def.dark) { - return `icon ${assetClassName(compiled.id, iconId)}`; - } + if (def?.className) return def.className; + if (def?.src && this.#assetReady(compiled, def.src)) { + return `icon ${assetClassName(compiled.id, iconId)}`; } if (compiled.id === BUILTIN_THEME_ID) { if (input.kind === "folder") return buildBuiltinFolderClass(); - if (iconId === "file") return buildBuiltinFileClass("default", languageId); + if (iconId === "file") + return buildBuiltinFileClass("default", languageId); return buildBuiltinFileClass(iconId, languageId); } return ""; } + #assetReady(compiled: CompiledTheme, src: string): boolean { + if (typeof Image === "undefined") return true; + const state = this.#assetStates.get(src); + if (state) return state === "ready"; + this.#assetStates.set(src, "loading"); + const image = new Image(); + const states = this.#assetStates; + const finish = (ready: boolean) => { + image.onload = image.onerror = null; + if ( + this.#compiled.get(compiled.id) !== compiled || + this.#activeId !== compiled.id || + this.#assetStates !== states + ) + return; + states.set(src, ready ? "ready" : "failed"); + if (!ready) + console.warn( + `[fileIcons] Theme '${compiled.id}' could not load '${src}'; using fallback`, + ); + if (!this.#assetRefreshQueued) { + this.#assetRefreshQueued = true; + const refresh = () => { + this.#assetRefreshQueued = false; + this.#emitChange(); + }; + if (typeof requestAnimationFrame === "function") + requestAnimationFrame(refresh); + else queueMicrotask(refresh); + } + }; + image.onload = () => finish(true); + image.onerror = () => finish(false); + image.src = src; + return false; + } + #applyThemeStyles(compiled: CompiledTheme): void { const doc = getDocument(); if (!doc) return; const rules: string[] = []; for (const [iconId, def] of compiled.icons) { - const src = pickSrc(def); - if (src) { - rules.push( - cssForSrc(assetClassName(compiled.id, iconId), src, !!def.monochrome), - ); - } - if (def.expandedSrc) { + if (def.src) rules.push( cssForSrc( - assetClassName(compiled.id, iconId, "expanded"), - def.expandedSrc, + assetClassName(compiled.id, iconId), + def.src, !!def.monochrome, ), ); - } } - let style = doc.head.querySelector(`style[data-file-icon="${compiled.id}"]`); + let style = doc.head.querySelector( + `style[data-file-icon="${compiled.id}"]`, + ); if (!rules.length) { style?.remove(); return; @@ -1182,15 +1001,9 @@ class FileIconRegistry { } } -function overrideKey( - kind: IconKind, - name: string, - caseSensitive: boolean, -): string { - return `${kind}:${caseSensitive ? name : name.toLowerCase()}`; -} - -function normalizeResource(resource: IconResource | string): NormalizedResource { +function normalizeResource( + resource: IconResource | string, +): NormalizedResource { if (typeof resource === "string") { return { kind: "file", name: basename(resource) }; } @@ -1206,19 +1019,23 @@ function applyLeadClass($tile: HTMLElement, className: string): void { const $lead = $tile.querySelector(":scope > span:first-child") || ($tile.firstElementChild as HTMLElement | null); - if (!$lead || $lead.classList.contains("text") || $lead.classList.contains("tail")) { + if ( + !$lead || + $lead.classList.contains("text") || + $lead.classList.contains("tail") + ) { return; } $lead.className = className; } -function pickSrc(def: IconDefinition, appearance?: "dark" | "light"): string { - if (appearance === "light" && def.light) return def.light; - if (appearance === "dark" && def.dark) return def.dark; - return def.src || def.dark || def.light || ""; -} - const fileIcons = new FileIconRegistry(); +export const fileIconApi = Object.freeze({ + register: fileIcons.register.bind(fileIcons), + icon: fileIcons.icon.bind(fileIcons), + onChange: fileIcons.onChange.bind(fileIcons), +}); + export { BUILTIN_THEME_ID, SCHEMA_VERSION }; export default fileIcons; diff --git a/src/lib/fileIconsBuiltin.ts b/src/lib/fileIconsBuiltin.ts index ab0f18c352..bc2ed6e67c 100644 --- a/src/lib/fileIconsBuiltin.ts +++ b/src/lib/fileIconsBuiltin.ts @@ -219,16 +219,14 @@ function createFileExtensions() { export function createBuiltinTheme() { return { id: BUILTIN_THEME_ID, - label: "Acode", + name: "Builtin", schemaVersion: SCHEMA_VERSION, fileNames: createFileNames(), fileExtensions: createFileExtensions(), - defaults: { - file: "file", - folder: "folder", - folderExpanded: "folder", - rootFolder: "folder", - rootFolderExpanded: "folder", - }, + file: "file", + folder: "folder", + folderExpanded: "folder", + rootFolder: "folder", + rootFolderExpanded: "folder", }; } diff --git a/src/lib/recents.js b/src/lib/recents.js index 1f583a8a07..eda542edcf 100644 --- a/src/lib/recents.js +++ b/src/lib/recents.js @@ -119,7 +119,7 @@ const recents = { text: name, subText: location, title: path, - icon: "folder", + fileIcon: { name, kind: "folder" }, className: "recent-entry", tailElement: tailElement, ontailclick: (e) => { @@ -153,7 +153,7 @@ const recents = { text: name, subText: location, title: path, - icon: helpers.getIconForFile(name), + fileIcon: { name, kind: "file" }, className: "recent-entry", tailElement: tailElement, ontailclick: (e) => { diff --git a/src/settings/appSettings.js b/src/settings/appSettings.js index f62463cacb..369ea293f6 100644 --- a/src/settings/appSettings.js +++ b/src/settings/appSettings.js @@ -229,7 +229,7 @@ export default function otherSettings() { }, { key: "iconTheme", - text: strings["icon theme"] || "Icon theme", + text: strings["icon pack"] || "Icon pack", value: values.iconTheme || "builtin", get select() { return fileIcons @@ -237,17 +237,17 @@ export default function otherSettings() { .map((theme) => [ theme.id, theme.available === false - ? `${theme.label} (${strings.unavailable || "unavailable"})` - : theme.label, + ? `${theme.name} (${strings.unavailable || "unavailable"})` + : theme.name, ]); }, valueText: (value) => { const theme = fileIcons.list().find((entry) => entry.id === value); - return theme?.label || value || "Acode"; + return theme?.name || value || "Builtin"; }, info: - strings["settings-info-icon-theme"] || - "Choose how files and folders are shown in the explorer and file lists. Plugin icon themes become available after they load.", + strings["settings-info-icon-pack"] || + "Choose how files and folders are shown in the explorer and file lists. Plugin icon packs become available after they load.", category: categories.interface, }, { diff --git a/src/settings/formatterSettings.js b/src/settings/formatterSettings.js index 512510d6d5..604caec673 100644 --- a/src/settings/formatterSettings.js +++ b/src/settings/formatterSettings.js @@ -1,7 +1,6 @@ import { getModes } from "cm/modelist"; import settingsPage from "components/settingsPage"; import appSettings from "lib/settings"; -import helpers from "utils/helpers"; export default function formatterSettings(languageName) { const title = strings.formatter; @@ -28,7 +27,7 @@ export default function formatterSettings(languageName) { return { key: name, text: caption, - icon: helpers.getIconForFile(`sample.${sampleExt}`), + fileIcon: { name: `sample.${sampleExt}` }, value: formatterID, valueText: (value) => { const formatter = formatters.find(({ id }) => id === value); diff --git a/src/sidebarApps/files/index.js b/src/sidebarApps/files/index.js index 1c9e900ec8..8421f4f194 100644 --- a/src/sidebarApps/files/index.js +++ b/src/sidebarApps/files/index.js @@ -1,6 +1,7 @@ import "./style.scss"; import Sidebar from "components/sidebar"; import settings from "lib/settings"; +import fileIcons from "lib/fileIcons"; /**@type {HTMLElement} */ let container; @@ -45,6 +46,9 @@ function initApp(el) { * @param {HTMLElement} el */ function onSelected(el) { + // Phone sidebars and inactive sidebar apps are detached, so document-wide + // icon updates cannot reach them until this view is shown again. + fileIcons.refreshRenderedIcons(container); const $scrollableLists = container.getAll(":scope .scroll[data-scroll-top]"); $scrollableLists.forEach(($el) => { $el.scrollTop = $el.dataset.scrollTop; diff --git a/src/sidebarApps/searchInFiles/cmResultView.js b/src/sidebarApps/searchInFiles/cmResultView.js index 05d4d839fe..807a4de8bb 100644 --- a/src/sidebarApps/searchInFiles/cmResultView.js +++ b/src/sidebarApps/searchInFiles/cmResultView.js @@ -125,16 +125,19 @@ export function createSearchResultView( } class FileIconWidget extends WidgetType { - constructor(className) { + constructor(className, name) { super(); this.className = className; + this.name = name; } eq(other) { - return other.className === this.className; + return other.className === this.className && other.name === this.name; } toDOM() { const span = document.createElement("span"); - span.className = `${this.className} cm-fileIcon`; + span.className = `${helpers.getIconForFile(this.name)} cm-fileIcon`; + span.dataset.fileIconName = this.name; + span.dataset.fileIconExtra = "cm-fileIcon"; return span; } ignoreEvent() { @@ -171,7 +174,7 @@ export function createSearchResultView( const iconClass = helpers.getIconForFile(fname); builder.push( Decoration.widget({ - widget: new FileIconWidget(iconClass), + widget: new FileIconWidget(iconClass, fname), side: -1, }).range(header.from), ); diff --git a/src/utils/helpers.js b/src/utils/helpers.js index ea06211d7a..e48e94bea0 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -34,7 +34,7 @@ export default { * @param {string} filename */ getIconForFile(filename) { - return fileIcons.getIconClass({ kind: "file", name: filename }); + return fileIcons.icon({ kind: "file", name: filename }); }, /** * Gets icon according to folder name and expansion state @@ -42,7 +42,7 @@ export default { * @param {{expanded?: boolean, isRoot?: boolean}} [options] */ getIconForFolder(name, options = {}) { - return fileIcons.getIconClass({ + return fileIcons.icon({ kind: "folder", name, expanded: options.expanded, diff --git a/tests/unit/fileIconAssets.test.ts b/tests/unit/fileIconAssets.test.ts new file mode 100644 index 0000000000..f0bc67c581 --- /dev/null +++ b/tests/unit/fileIconAssets.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fileIcons, { fileIconApi } from "lib/fileIcons"; + +const requests: FakeImage[] = []; +class FakeImage { + src = ""; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor() { + requests.push(this); + } +} +beforeEach(() => { + vi.stubGlobal("Image", FakeImage); + vi.stubGlobal("requestAnimationFrame", (fn: () => void) => { + queueMicrotask(fn); + return 0; + }); +}); +afterEach(() => { + fileIcons.resetForTests(); + requests.length = 0; + document.head.innerHTML = ""; + document.body.innerHTML = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const pack = (id = "pack") => ({ + id, + pluginId: "test.plugin", + icons: "file:///icons/", + fileExtensions: { js: "js" }, + folder: "closed", + folderExpanded: "open", +}); + +describe("icon assets", () => { + it("loads only resolved assets of the active theme, sharing requests", () => { + fileIcons.register(pack()); + expect(document.querySelector("style[data-file-icon]")).toBeNull(); + expect(requests).toHaveLength(0); + fileIcons.use("pack", { persist: false }); + expect(requests).toHaveLength(0); + expect(fileIcons.resolve("a.js").themeId).toBe("builtin"); + fileIcons.resolve("b.js"); + expect(requests).toHaveLength(1); + requests[0].onload?.(); + expect(fileIcons.resolve("a.js").themeId).toBe("pack"); + }); + + it("keeps a visible fallback after failure and reports it once", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + fileIcons.register(pack()); + fileIcons.use("pack", { persist: false }); + fileIcons.resolve("a.js"); + requests[0].onerror?.(); + expect(fileIcons.resolve("a.js").themeId).toBe("builtin"); + fileIcons.resolve("a.js"); + expect(requests).toHaveLength(1); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("uses the closed asset when the expanded asset fails", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + fileIcons.register(pack()); + fileIcons.use("pack", { persist: false }); + fileIcons.resolve({ kind: "folder", name: "src", expanded: true }); + requests.find((r) => r.src.endsWith("open.svg"))!.onerror?.(); + requests.find((r) => r.src.endsWith("closed.svg"))!.onload?.(); + expect( + fileIcons.resolve({ kind: "folder", name: "src", expanded: true }).iconId, + ).toBe("closed"); + }); + + it("ignores stale asset completion after theme replacement", () => { + fileIcons.register(pack()); + fileIcons.use("pack", { persist: false }); + fileIcons.resolve("a.js"); + const old = requests[0]; + fileIcons.register({ ...pack(), icons: "file:///replacement/" }); + old.onload?.(); + expect(fileIcons.resolve("a.js").themeId).toBe("builtin"); + expect(requests.at(-1)?.src).toBe("file:///replacement/js.svg"); + }); + + it("updates a recycled row using its current filename", async () => { + fileIcons.register(pack()); + fileIcons.use("pack", { persist: false }); + document.body.innerHTML = + '
'; + fileIcons.refreshRenderedIcons(); + const tile = document.body.firstElementChild as HTMLElement; + tile.dataset.name = "notes.txt"; + requests[0].onload?.(); + await Promise.resolve(); + expect(tile.firstElementChild?.className).toBe(fileIcons.icon("notes.txt")); + }); + + it("uses distinct CSS classes for IDs that previously collided", () => { + fileIcons.register({ + id: "ids", + pluginId: "test.plugin", + icons: { + "foo.bar": { src: "file:///a.svg" }, + "foo-bar": { src: "file:///b.svg" }, + }, + fileNames: { a: "foo.bar", b: "foo-bar" }, + }); + fileIcons.use("ids", { persist: false }); + fileIcons.resolve("a"); + fileIcons.resolve("b"); + for (const request of requests) request.onload?.(); + expect(fileIcons.icon("a")).not.toBe(fileIcons.icon("b")); + }); + + it("exposes only the supported plugin surface", () => { + expect(Object.keys(fileIconApi)).toEqual(["register", "icon", "onChange"]); + }); +}); diff --git a/tests/unit/fileIconExample.test.ts b/tests/unit/fileIconExample.test.ts new file mode 100644 index 0000000000..d5a02e1da6 --- /dev/null +++ b/tests/unit/fileIconExample.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { afterEach, expect, it } from "vitest"; +import fileIcons, { fileIconApi } from "lib/fileIcons"; + +afterEach(() => fileIcons.resetForTests()); + +it("loads the Material Icons example through the public API", async () => { + const root = path.resolve("examples/material-icons"); + // Examples may be omitted from source-only checkouts. + if (!fs.existsSync(root)) return; + let init: (base: string) => Promise; + let unmount: () => void; + vm.runInNewContext(fs.readFileSync(path.join(root, "main.js"), "utf8"), { + PLUGIN_DIR: "/plugins", + acode: { + require(name: string) { + if (name === "fileIcons") return fileIconApi; + if (name === "Url") + return { join: (...parts: string[]) => parts.join("/") }; + if (name === "fs") + return (name: string) => ({ + readFile: async () => + JSON.parse( + fs.readFileSync(path.join(root, path.basename(name)), "utf8"), + ), + }); + throw new Error(name); + }, + setPluginInit(_id: string, fn: typeof init) { + init = fn; + }, + setPluginUnmount(_id: string, fn: typeof unmount) { + unmount = fn; + }, + }, + }); + await init!("file:///plugins/material/"); + fileIcons.use("sebastianjnuwu.material.icons", { persist: false }); + expect(fileIcons.resolve("app.js").themeId).toBe( + "sebastianjnuwu.material.icons", + ); + expect( + fileIcons.resolve({ name: "src", kind: "folder", expanded: true }).themeId, + ).toBe("sebastianjnuwu.material.icons"); + unmount!(); + expect(fileIcons.active().id).toBe("builtin"); +}); diff --git a/tests/unit/fileIconSidebar.test.ts b/tests/unit/fileIconSidebar.test.ts new file mode 100644 index 0000000000..c83bfe0fa9 --- /dev/null +++ b/tests/unit/fileIconSidebar.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import fileIcons from "lib/fileIcons"; + +const sidebar = vi.hoisted(() => ({ on: vi.fn() })); +vi.mock("components/sidebar", () => ({ default: sidebar })); +vi.mock("lib/settings", () => ({ default: {} })); + +beforeEach(() => { + vi.stubGlobal("strings", { files: "Files", "open folder": "Open folder" }); + vi.stubGlobal("editorManager", { on: vi.fn() }); +}); +afterEach(() => { + fileIcons.resetForTests(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +it("refreshes detached files and expanded folders when the phone sidebar reopens", async () => { + const { default: filesApp } = await import( + "../../src/sidebarApps/files/index.js" + ); + const container = document.createElement("div"); + Object.assign(container, { + getAll: (selector: string) => + Array.from(container.querySelectorAll(selector)), + }); + const init = filesApp[3] as (element: HTMLElement) => void; + init(container); + container.innerHTML = + '
'; + const register = (id: string) => + fileIcons.register({ + id, + pluginId: "test.plugin", + icons: { + file: { className: `${id}-file` }, + closed: { className: `${id}-closed` }, + open: { className: `${id}-open` }, + root: { className: `${id}-root` }, + }, + file: "file", + folder: "closed", + folderExpanded: "open", + rootFolderExpanded: "root", + }); + register("first"); + register("second"); + document.body.append(container); + fileIcons.use("first", { persist: false }); + container.remove(); // hide() removes the phone sidebar from the document + fileIcons.use("second", { persist: false }); + expect(container.querySelector('[data-type="file"] span')?.className).toBe( + "first-file", + ); + document.body.append(container); + const onShow = sidebar.on.mock.calls.find(([event]) => event === "show")![1]; + onShow(); + expect(container.querySelector('[data-type="file"] span')?.className).toBe( + "second-file", + ); + expect(container.querySelector('[data-type="dir"] span')?.className).toBe( + "second-open", + ); + expect(container.querySelector('[data-type="root"] span')?.className).toBe( + "second-root", + ); + expect(container.querySelector(".hidden")).toBeNull(); + + // Selecting the Files tab also refreshes an inactive, detached sidebar app. + container.remove(); + fileIcons.use("builtin", { persist: false }); + (filesApp[5] as () => void)(); + expect(container.querySelector('[data-type="dir"] span')?.className).toBe( + "icon folder", + ); + expect(container.querySelector('[data-type="file"] span')?.className).toBe( + fileIcons.icon("app.js"), + ); + expect(fileIcons.active().name).toBe("Builtin"); +}); + +it("replaces fallbacks with loaded assets after reopening without toggling folders", async () => { + const { default: filesApp } = await import( + "../../src/sidebarApps/files/index.js" + ); + const requests: Array<{ + src: string; + onload: (() => void) | null; + onerror: (() => void) | null; + }> = []; + class ImageStub { + src = ""; + onload = null; + onerror = null; + constructor() { + requests.push(this); + } + } + vi.stubGlobal("Image", ImageStub); + vi.stubGlobal("requestAnimationFrame", (fn: () => void) => { + queueMicrotask(fn); + return 0; + }); + const container = document.createElement("div"); + Object.assign(container, { + getAll: (selector: string) => + Array.from(container.querySelectorAll(selector)), + }); + (filesApp[3] as (element: HTMLElement) => void)(container); + container.innerHTML = + '
'; + fileIcons.register({ + id: "images", + pluginId: "test.plugin", + icons: "file:///icons/", + file: "file", + folder: "folder", + folderExpanded: "folder-open", + }); + fileIcons.use("images", { persist: false }); + // Match the phone sidebar: it may refresh immediately before being attached. + const show = sidebar.on.mock.calls.find(([event]) => event === "show")![1]; + show(); + document.body.append(container); + expect(requests.length).toBeGreaterThan(0); + for (const request of requests) request.onload?.(); + await Promise.resolve(); + expect(fileIcons.resolve("app.js").themeId).toBe("images"); + expect(container.querySelector('[data-type="file"] span')?.className).toBe( + fileIcons.icon("app.js"), + ); + expect(container.querySelector('[data-type="dir"] span')?.className).toBe( + fileIcons.icon({ name: "src", kind: "folder", expanded: true }), + ); + expect(container.querySelector(".hidden")).toBeNull(); +}); diff --git a/tests/unit/fileIconTheme.test.ts b/tests/unit/fileIconTheme.test.ts index e0bba3cd98..1e085a57e3 100644 --- a/tests/unit/fileIconTheme.test.ts +++ b/tests/unit/fileIconTheme.test.ts @@ -86,6 +86,7 @@ describe("plugin icon themes", () => { it("does not apply inactive plugin themes", () => { fileIcons.register({ + pluginId: "test.plugin", id: "other-icons", name: "Other", icons: { @@ -100,6 +101,7 @@ describe("plugin icon themes", () => { it("resolves SVG packs from an icons folder like VS Code iconPath", () => { fileIcons.register({ + pluginId: "test.plugin", id: "pack", name: "Pack", icons: "https://example.com/icons/", @@ -110,13 +112,14 @@ describe("plugin icon themes", () => { }); fileIcons.use("pack", { persist: false }); - expect(fileIcons.icon("app.js")).toContain("file-icon--pack--javascript"); - expect(fileIcons.icon({ kind: "folder", name: "src" })).toContain( - "file-icon--pack--folder-src", + expect(fileIcons.resolve("app.js").iconId).toBe("javascript"); + expect(fileIcons.resolve({ kind: "folder", name: "src" }).iconId).toBe( + "folder-src", ); expect( - fileIcons.icon({ kind: "folder", name: "other", expanded: true }), - ).toContain("file-icon--pack--folder-open"); + fileIcons.resolve({ kind: "folder", name: "other", expanded: true }) + .iconId, + ).toBe("folder-open"); }); it("falls back to the built-in theme when the active plugin unregisters", () => { @@ -170,17 +173,9 @@ describe("plugin icon themes", () => { ]); }); - it("lets user overrides win over theme associations", () => { - fileIcons.setOverride({ - kind: "file", - name: "package.json", - icon: "webpack", - }); - expect(fileIcons.resolve("package.json").iconId).toBe("webpack"); - }); - it("rejects invalid themes without replacing a previous valid version", () => { fileIcons.register({ + pluginId: "test.plugin", id: "stable-icons", name: "Stable", icons: { js: { className: "icon stable-js" } }, @@ -189,7 +184,8 @@ describe("plugin icon themes", () => { fileIcons.use("stable-icons", { persist: false }); expect(() => - fileIcons.update("stable-icons", { + fileIcons.register({ + pluginId: "test.plugin", id: "stable-icons", fileExtensions: { js: "js" }, icons: { js: { src: "javascript:alert(1)" } }, @@ -199,16 +195,121 @@ describe("plugin icon themes", () => { expect(fileIcons.icon("app.js")).toBe("icon stable-js"); }); - it("lets later associations win when keys collide", () => { + it("rejects conflicting normalized associations", () => { + expect(() => + fileIcons.register({ + id: "duplicates", + pluginId: "test.plugin", + icons: { a: { className: "a" }, b: { className: "b" } }, + fileExtensions: { js: "a", JS: "b" }, + }), + ).toThrow(/Conflicting fileExtension/); + }); +}); + +describe("theme contract", () => { + const theme = (id = "test") => ({ + id, + pluginId: "test.plugin", + icons: { + closed: { className: "custom-closed" }, + open: { className: "custom-open" }, + }, + folder: "closed", + }); + + it("inherits a custom closed folder for expanded and root folders", () => { + fileIcons.register(theme()); + fileIcons.use("test", { persist: false }); + for (const isRoot of [false, true]) { + expect( + fileIcons.icon({ + kind: "folder", + name: "other", + expanded: true, + isRoot, + }), + ).toBe("custom-closed"); + } + }); + + it("honors explicit expanded and root icons", () => { fileIcons.register({ - id: "dup-icons", - fileExtensions: { js: "js", JS: "javascript" }, - icons: { - js: { className: "a" }, - javascript: { className: "b" }, - }, + ...theme(), + folderExpanded: "open", + rootFolder: "closed", }); - fileIcons.use("dup-icons", { persist: false }); - expect(fileIcons.resolve("app.js").iconId).toBe("javascript"); + fileIcons.use("test", { persist: false }); + expect( + fileIcons.icon({ kind: "folder", name: "other", expanded: true }), + ).toBe("custom-open"); + expect( + fileIcons.icon({ + kind: "folder", + name: "other", + expanded: true, + isRoot: true, + }), + ).toBe("custom-closed"); + }); + + it("does not let an obsolete disposal remove its replacement", () => { + const old = fileIcons.register(theme()); + const current = fileIcons.register({ ...theme(), name: "Replacement" }); + old.dispose(); + expect(fileIcons.list().find((t) => t.id === "test")?.name).toBe( + "Replacement", + ); + current.dispose(); + current.dispose(); + expect(fileIcons.list().some((t) => t.id === "test")).toBe(false); + }); + + it("rejects another plugin replacing the same id", () => { + fileIcons.register(theme()); + expect(() => + fileIcons.register({ ...theme(), pluginId: "other.plugin" }), + ).toThrow(/another plugin/); + }); + + it("reports missing references and unsupported fields", () => { + expect(() => + fileIcons.register({ ...theme(), fileExtensions: { js: "missing" } }), + ).toThrow(/fileExtensions.js.*missing/); + expect(() => + fileIcons.register({ ...theme(), label: "Alias" } as never), + ).toThrow(/theme.label/); + expect(() => + fileIcons.register({ + ...theme(), + icons: { bad: { src: "file:///bad.svg", light: "file:///light.svg" } }, + } as never), + ).toThrow(/icons.bad.light/); + }); + + it("requires ownership for automatic cleanup", () => { + expect(() => fileIcons.register({ id: "missing-owner" } as never)).toThrow( + /pluginId/, + ); + }); + + it("does not treat a dotfile as an extension", () => { + fileIcons.register({ ...theme(), fileExtensions: { env: "open" } }); + fileIcons.use("test", { persist: false }); + expect(fileIcons.resolve(".env").source).toBe("default"); + expect(fileIcons.resolve("project.env").iconId).toBe("open"); + }); + + it("does not infer undeclared open assets", () => { + fileIcons.register({ + id: "directory", + pluginId: "test.plugin", + icons: "file:///icons/", + folderNames: { src: "source" }, + }); + fileIcons.use("directory", { persist: false }); + expect( + fileIcons.resolve({ kind: "folder", name: "src", expanded: true }).iconId, + ).toBe("source"); }); }); From bfebf679777471e9d008b06e7284c042c1bccbdb Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:03:59 +0530 Subject: [PATCH 3/9] fix --- tests/unit/fileIconExample.test.ts | 49 ------------------------------ 1 file changed, 49 deletions(-) delete mode 100644 tests/unit/fileIconExample.test.ts diff --git a/tests/unit/fileIconExample.test.ts b/tests/unit/fileIconExample.test.ts deleted file mode 100644 index d5a02e1da6..0000000000 --- a/tests/unit/fileIconExample.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import vm from "node:vm"; -import { afterEach, expect, it } from "vitest"; -import fileIcons, { fileIconApi } from "lib/fileIcons"; - -afterEach(() => fileIcons.resetForTests()); - -it("loads the Material Icons example through the public API", async () => { - const root = path.resolve("examples/material-icons"); - // Examples may be omitted from source-only checkouts. - if (!fs.existsSync(root)) return; - let init: (base: string) => Promise; - let unmount: () => void; - vm.runInNewContext(fs.readFileSync(path.join(root, "main.js"), "utf8"), { - PLUGIN_DIR: "/plugins", - acode: { - require(name: string) { - if (name === "fileIcons") return fileIconApi; - if (name === "Url") - return { join: (...parts: string[]) => parts.join("/") }; - if (name === "fs") - return (name: string) => ({ - readFile: async () => - JSON.parse( - fs.readFileSync(path.join(root, path.basename(name)), "utf8"), - ), - }); - throw new Error(name); - }, - setPluginInit(_id: string, fn: typeof init) { - init = fn; - }, - setPluginUnmount(_id: string, fn: typeof unmount) { - unmount = fn; - }, - }, - }); - await init!("file:///plugins/material/"); - fileIcons.use("sebastianjnuwu.material.icons", { persist: false }); - expect(fileIcons.resolve("app.js").themeId).toBe( - "sebastianjnuwu.material.icons", - ); - expect( - fileIcons.resolve({ name: "src", kind: "folder", expanded: true }).themeId, - ).toBe("sebastianjnuwu.material.icons"); - unmount!(); - expect(fileIcons.active().id).toBe("builtin"); -}); From a069e7d7b56426c5fd559775a12bba7656f76c89 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:14:57 +0530 Subject: [PATCH 4/9] format --- src/dialogs/select.js | 2 +- src/sidebarApps/files/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dialogs/select.js b/src/dialogs/select.js index 938288d6e5..c34567f712 100644 --- a/src/dialogs/select.js +++ b/src/dialogs/select.js @@ -2,8 +2,8 @@ import Checkbox from "components/checkbox"; import tile from "components/tile"; import DOMPurify from "dompurify"; import actionStack from "lib/actionStack"; -import restoreTheme from "lib/restoreTheme"; import fileIcons from "lib/fileIcons"; +import restoreTheme from "lib/restoreTheme"; /** * @typedef {object} SelectOptions diff --git a/src/sidebarApps/files/index.js b/src/sidebarApps/files/index.js index 8421f4f194..4baca7cec7 100644 --- a/src/sidebarApps/files/index.js +++ b/src/sidebarApps/files/index.js @@ -1,7 +1,7 @@ import "./style.scss"; import Sidebar from "components/sidebar"; -import settings from "lib/settings"; import fileIcons from "lib/fileIcons"; +import settings from "lib/settings"; /**@type {HTMLElement} */ let container; From b83a5044ebe77a7c76ce08a0a697d760fa34c1fd Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:24:47 +0530 Subject: [PATCH 5/9] chore: i18n strings --- src/lang/ar-ye.json | 5 ++++- src/lang/be-by.json | 5 ++++- src/lang/bn-bd.json | 5 ++++- src/lang/cs-cz.json | 5 ++++- src/lang/de-de.json | 5 ++++- src/lang/en-us.json | 2 +- src/lang/es-sv.json | 5 ++++- src/lang/fr-fr.json | 5 ++++- src/lang/he-il.json | 5 ++++- src/lang/hi-in.json | 5 ++++- src/lang/hu-hu.json | 5 ++++- src/lang/id-id.json | 5 ++++- src/lang/index.d.ts | 3 +++ src/lang/ir-fa.json | 5 ++++- src/lang/it-it.json | 5 ++++- src/lang/ja-jp.json | 5 ++++- src/lang/ko-kr.json | 5 ++++- src/lang/ln-ln.json | 5 ++++- src/lang/ml-in.json | 5 ++++- src/lang/mm-unicode.json | 5 ++++- src/lang/mm-zawgyi.json | 5 ++++- src/lang/pl-pl.json | 5 ++++- src/lang/pt-br.json | 5 ++++- src/lang/pu-in.json | 5 ++++- src/lang/ru-ru.json | 5 ++++- src/lang/tl-ph.json | 5 ++++- src/lang/tr-tr.json | 5 ++++- src/lang/uk-ua.json | 5 ++++- src/lang/uz-uz.json | 5 ++++- src/lang/vi-vn.json | 5 ++++- src/lang/zh-cn.json | 5 ++++- src/lang/zh-hant.json | 5 ++++- src/lang/zh-tw.json | 5 ++++- src/settings/appSettings.js | 6 ++---- 34 files changed, 130 insertions(+), 36 deletions(-) diff --git a/src/lang/ar-ye.json b/src/lang/ar-ye.json index eb5a33428d..6939fd6b68 100644 --- a/src/lang/ar-ye.json +++ b/src/lang/ar-ye.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/be-by.json b/src/lang/be-by.json index b59491ea87..9561a723fc 100644 --- a/src/lang/be-by.json +++ b/src/lang/be-by.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/bn-bd.json b/src/lang/bn-bd.json index 28d101f00c..23221edf83 100644 --- a/src/lang/bn-bd.json +++ b/src/lang/bn-bd.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/cs-cz.json b/src/lang/cs-cz.json index 2646d43475..52596946d4 100644 --- a/src/lang/cs-cz.json +++ b/src/lang/cs-cz.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/de-de.json b/src/lang/de-de.json index bf78e973df..5ef81148c2 100644 --- a/src/lang/de-de.json +++ b/src/lang/de-de.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/en-us.json b/src/lang/en-us.json index bb3c91bcbd..b3d9985084 100644 --- a/src/lang/en-us.json +++ b/src/lang/en-us.json @@ -180,7 +180,7 @@ "dark": "Dark", "file browser": "File Browser", "icon pack": "Icon pack", - "settings-info-icon-pack": "Choose how files and folders are shown in the explorer and file lists. Plugin icon packs become available after they load.", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", "unavailable": "unavailable", "operation not permitted": "Operation not permitted", "no such file or directory": "No such file or directory", diff --git a/src/lang/es-sv.json b/src/lang/es-sv.json index 97e96193ac..1942bb00a4 100644 --- a/src/lang/es-sv.json +++ b/src/lang/es-sv.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/fr-fr.json b/src/lang/fr-fr.json index 103ae9920d..ec0b418df2 100644 --- a/src/lang/fr-fr.json +++ b/src/lang/fr-fr.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/he-il.json b/src/lang/he-il.json index e0b76ca2e3..35b7a716ce 100644 --- a/src/lang/he-il.json +++ b/src/lang/he-il.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/hi-in.json b/src/lang/hi-in.json index c042240877..f16dae7b26 100644 --- a/src/lang/hi-in.json +++ b/src/lang/hi-in.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/hu-hu.json b/src/lang/hu-hu.json index c37b3e26e3..4fe633111a 100644 --- a/src/lang/hu-hu.json +++ b/src/lang/hu-hu.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/id-id.json b/src/lang/id-id.json index 4d45630eb0..2e73dca559 100644 --- a/src/lang/id-id.json +++ b/src/lang/id-id.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/index.d.ts b/src/lang/index.d.ts index f643241de0..7873fb8b6a 100644 --- a/src/lang/index.d.ts +++ b/src/lang/index.d.ts @@ -182,6 +182,9 @@ declare type LangStrings = { "light": string; "dark": string; "file browser": string; + "icon pack": string; + "settings-info-icon-pack": string; + "unavailable": string; "operation not permitted": string; "no such file or directory": string; "input/output error": string; diff --git a/src/lang/ir-fa.json b/src/lang/ir-fa.json index e9f47b3b15..8a1db6e17f 100644 --- a/src/lang/ir-fa.json +++ b/src/lang/ir-fa.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/it-it.json b/src/lang/it-it.json index 5cced6fc43..19ddbba944 100644 --- a/src/lang/it-it.json +++ b/src/lang/it-it.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/ja-jp.json b/src/lang/ja-jp.json index f80382673b..03ec6d4954 100644 --- a/src/lang/ja-jp.json +++ b/src/lang/ja-jp.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/ko-kr.json b/src/lang/ko-kr.json index f33b9773c3..fd76716b43 100644 --- a/src/lang/ko-kr.json +++ b/src/lang/ko-kr.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/ln-ln.json b/src/lang/ln-ln.json index e9d379d27d..a0de1f1e17 100644 --- a/src/lang/ln-ln.json +++ b/src/lang/ln-ln.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/ml-in.json b/src/lang/ml-in.json index 04cf2f7878..4b1af01ee2 100644 --- a/src/lang/ml-in.json +++ b/src/lang/ml-in.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/mm-unicode.json b/src/lang/mm-unicode.json index f14e6b82bc..74364aff07 100644 --- a/src/lang/mm-unicode.json +++ b/src/lang/mm-unicode.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/mm-zawgyi.json b/src/lang/mm-zawgyi.json index 5fb0e344bb..d1f72c1e89 100644 --- a/src/lang/mm-zawgyi.json +++ b/src/lang/mm-zawgyi.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/pl-pl.json b/src/lang/pl-pl.json index 5aafd67b6c..e1729da06e 100644 --- a/src/lang/pl-pl.json +++ b/src/lang/pl-pl.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/pt-br.json b/src/lang/pt-br.json index 64f45d6402..6a7516d6cd 100644 --- a/src/lang/pt-br.json +++ b/src/lang/pt-br.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/pu-in.json b/src/lang/pu-in.json index 60912d7a67..70ac968a62 100644 --- a/src/lang/pu-in.json +++ b/src/lang/pu-in.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/ru-ru.json b/src/lang/ru-ru.json index 1628494470..0d61d611bf 100644 --- a/src/lang/ru-ru.json +++ b/src/lang/ru-ru.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/tl-ph.json b/src/lang/tl-ph.json index 3cfed0807a..bb7b2ac749 100644 --- a/src/lang/tl-ph.json +++ b/src/lang/tl-ph.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/tr-tr.json b/src/lang/tr-tr.json index 39898581ad..de2f19aaed 100644 --- a/src/lang/tr-tr.json +++ b/src/lang/tr-tr.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/uk-ua.json b/src/lang/uk-ua.json index 84f79fc2bb..43f8a786ab 100644 --- a/src/lang/uk-ua.json +++ b/src/lang/uk-ua.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/uz-uz.json b/src/lang/uz-uz.json index 04c6f91dc2..8ecb20a89b 100644 --- a/src/lang/uz-uz.json +++ b/src/lang/uz-uz.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/vi-vn.json b/src/lang/vi-vn.json index 54f3850ff2..ce61c46ac2 100644 --- a/src/lang/vi-vn.json +++ b/src/lang/vi-vn.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/zh-cn.json b/src/lang/zh-cn.json index c945e84c8c..eafd3a0410 100644 --- a/src/lang/zh-cn.json +++ b/src/lang/zh-cn.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/zh-hant.json b/src/lang/zh-hant.json index 86399804bc..4f2d88a12b 100644 --- a/src/lang/zh-hant.json +++ b/src/lang/zh-hant.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/lang/zh-tw.json b/src/lang/zh-tw.json index 01f2957f16..679abc6d34 100644 --- a/src/lang/zh-tw.json +++ b/src/lang/zh-tw.json @@ -881,5 +881,8 @@ "wrap-indent-same": "Same", "wrap-indent-indent": "Indent (+1 level)", "wrap-indent-deep": "Deep indent (+2 levels)", - "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size." + "settings-info-editor-wrapping-indent": "Choose whether wrapped text starts at the left edge, matches the original line's indentation, or is indented further. Each extra level uses your tab size.", + "icon pack": "Icon pack", + "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", + "unavailable": "unavailable" } diff --git a/src/settings/appSettings.js b/src/settings/appSettings.js index 369ea293f6..f6f46e65d2 100644 --- a/src/settings/appSettings.js +++ b/src/settings/appSettings.js @@ -229,7 +229,7 @@ export default function otherSettings() { }, { key: "iconTheme", - text: strings["icon pack"] || "Icon pack", + text: strings["icon pack"], value: values.iconTheme || "builtin", get select() { return fileIcons @@ -245,9 +245,7 @@ export default function otherSettings() { const theme = fileIcons.list().find((entry) => entry.id === value); return theme?.name || value || "Builtin"; }, - info: - strings["settings-info-icon-pack"] || - "Choose how files and folders are shown in the explorer and file lists. Plugin icon packs become available after they load.", + info: strings["settings-info-icon-pack"], category: categories.interface, }, { From 949370bbde751d4d9959413f04d0cae295340dd5 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:40:15 +0530 Subject: [PATCH 6/9] fix: ownership and recycle name --- src/components/fileTree/index.js | 25 +++++++--- src/lib/acode.js | 5 +- src/lib/fileIcons.ts | 55 ++++++++++++++++++--- src/lib/loadPlugin.js | 6 +++ tests/unit/fileIconAssets.test.ts | 8 +++- tests/unit/fileIconOwnership.test.ts | 63 ++++++++++++++++++++++++ tests/unit/fileTreeRecycling.test.ts | 72 ++++++++++++++++++++++++++++ 7 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 tests/unit/fileIconOwnership.test.ts create mode 100644 tests/unit/fileTreeRecycling.test.ts diff --git a/src/components/fileTree/index.js b/src/components/fileTree/index.js index 3c0f5f9d8b..caf20bda00 100644 --- a/src/components/fileTree/index.js +++ b/src/components/fileTree/index.js @@ -143,6 +143,7 @@ export default class FileTree { this.childTrees.delete(recycledEl._folderUrl); } recycledEl.$ul.innerHTML = ""; + recycledEl.$ul._fileTree = null; } recycledEl._folderUrl = url; @@ -174,11 +175,13 @@ export default class FileTree { $wrapper.append($title, $content); // Child file tree for nested folders - let childTree = null; $content._fileTree = null; const toggle = async () => { + const name = $title.dataset.name; + const url = $title.dataset.url; const isExpanded = !$wrapper.classList.contains("hidden"); + let childTree = $content._fileTree; if (isExpanded) { // Collapse @@ -225,7 +228,12 @@ export default class FileTree { $title.addEventListener("contextmenu", (e) => { e.stopPropagation(); - this.options.onContextMenu?.("dir", url, name, $title); + this.options.onContextMenu?.( + "dir", + $title.dataset.url, + $title.dataset.name, + $title, + ); }); // Check if folder should be expanded from saved state @@ -239,9 +247,9 @@ export default class FileTree { expanded: { get: () => !$wrapper.classList.contains("hidden") }, unclasped: { get: () => !$wrapper.classList.contains("hidden") }, // Legacy compatibility $ul: { get: () => $content }, - fileTree: { get: () => childTree }, + fileTree: { get: () => $content._fileTree }, refresh: { - value: () => childTree?.refresh(), + value: () => $content._fileTree?.refresh(), }, expand: { value: () => !$wrapper.classList.contains("hidden") || toggle(), @@ -298,12 +306,17 @@ export default class FileTree { $tile.addEventListener("click", (e) => { e.stopPropagation(); - this.options.onFileClick?.(url, name); + this.options.onFileClick?.($tile.dataset.url, $tile.dataset.name); }); $tile.addEventListener("contextmenu", (e) => { e.stopPropagation(); - this.options.onContextMenu?.("file", url, name, $tile); + this.options.onContextMenu?.( + "file", + $tile.dataset.url, + $tile.dataset.name, + $tile, + ); }); return $tile; diff --git a/src/lib/acode.js b/src/lib/acode.js index eb60bec5d0..24ac2dadef 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -51,7 +51,7 @@ import windowResize from "handlers/windowResize"; import actionStack from "lib/actionStack"; import commands from "lib/commands"; import EditorFile from "lib/editorFile"; -import fileIcons, { fileIconApi } from "lib/fileIcons"; +import fileIcons from "lib/fileIcons"; import fileIndex from "lib/fileIndex"; import files from "lib/fileList"; import fileTypeHandler from "lib/fileTypeHandler"; @@ -404,7 +404,6 @@ class Acode { deprecatedFileList.replacement = "fileIndex"; this.define("fileList", deprecatedFileList); this.define("fileIndex", fileIndex); - this.define("fileIcons", fileIconApi); this.define("fs", fsOperation); this.define("confirm", confirm); this.define("helpers", helpers); @@ -560,6 +559,8 @@ class Acode { } require(module) { + if (module.toLowerCase() === "fileicons") + return fileIcons.getPluginApi(document.currentScript); return this.#modules[module.toLowerCase()]; } diff --git a/src/lib/fileIcons.ts b/src/lib/fileIcons.ts index 66e14088f9..ebcf6fb75d 100644 --- a/src/lib/fileIcons.ts +++ b/src/lib/fileIcons.ts @@ -444,6 +444,50 @@ function lastExtension(name: string): string { } class FileIconRegistry { + #pluginScopes = new Map(); + #scriptApis = new WeakMap< + HTMLScriptElement, + ReturnType + >(); + + /** Called by the loader before executing a plugin script. */ + bindPlugin(script: HTMLScriptElement, pluginId: string) { + const previous = this.#pluginScopes.get(pluginId); + if (previous) throw new Error(`Plugin '${pluginId}' is already bound`); + const scope = { active: true }; + this.#pluginScopes.set(pluginId, scope); + const assertActive = () => { + if (!scope.active) + throw new Error(`Icon API for plugin '${pluginId}' has been unloaded`); + }; + const api = Object.freeze({ + register: ( + pack: Omit & { pluginId?: string }, + ) => { + assertActive(); + if (pack.pluginId !== undefined && pack.pluginId !== pluginId) { + throw new Error( + `Icon pack pluginId must match loading plugin '${pluginId}'`, + ); + } + return this.register({ ...pack, pluginId }); + }, + icon: this.icon.bind(this), + onChange: this.onChange.bind(this), + }); + this.#scriptApis.set(script, api); + return api; + } + + getPluginApi(script: HTMLScriptElement | null) { + const api = script && this.#scriptApis.get(script); + if (!api) + throw new Error( + 'Require "fileIcons" in the plugin main script, or use options.fileIcons in the init callback', + ); + return api; + } + #compiled = new Map(); #listeners = new Set< (info: { activeId: string; preferredId: string }) => void @@ -510,6 +554,9 @@ class FileIconRegistry { unregisterByPlugin(pluginId: string): void { if (!pluginId) return; + const scope = this.#pluginScopes.get(pluginId); + if (scope) scope.active = false; + this.#pluginScopes.delete(pluginId); for (const [id, compiled] of [...this.#compiled]) { if (compiled.pluginId === pluginId) this.unregister(id); } @@ -642,6 +689,8 @@ class FileIconRegistry { } resetForTests(): void { + for (const id of this.#pluginScopes.keys()) this.unregisterByPlugin(id); + this.#scriptApis = new WeakMap(); for (const id of [...this.#compiled.keys()]) { if (id !== BUILTIN_THEME_ID) this.unregister(id); } @@ -1031,11 +1080,5 @@ function applyLeadClass($tile: HTMLElement, className: string): void { const fileIcons = new FileIconRegistry(); -export const fileIconApi = Object.freeze({ - register: fileIcons.register.bind(fileIcons), - icon: fileIcons.icon.bind(fileIcons), - onChange: fileIcons.onChange.bind(fileIcons), -}); - export { BUILTIN_THEME_ID, SCHEMA_VERSION }; export default fileIcons; diff --git a/src/lib/loadPlugin.js b/src/lib/loadPlugin.js index 2272163ec8..e719965bf8 100644 --- a/src/lib/loadPlugin.js +++ b/src/lib/loadPlugin.js @@ -3,6 +3,7 @@ import Page from "components/page"; import helpers from "utils/helpers"; import Url from "utils/Url"; import actionStack from "./actionStack"; +import fileIcons from "./fileIcons"; import generatePluginContext, { connect } from "./pluginContext"; export default async function loadPlugin(pluginId, justInstalled = false) { @@ -48,7 +49,10 @@ export default async function loadPlugin(pluginId, justInstalled = false) { ); + const iconApi = fileIcons.bindPlugin($script, pluginId); + $script.onerror = (error) => { + fileIcons.unregisterByPlugin(pluginId); reject( new Error( `Failed to load script for plugin ${pluginId}: ${error.message || error}`, @@ -77,6 +81,7 @@ export default async function loadPlugin(pluginId, justInstalled = false) { } await acode.initPlugin(pluginId, baseUrl, $page, { + fileIcons: iconApi, cacheFileUrl: await helpers.toInternalUri(cacheFile), cacheFile: fsOperation(cacheFile), firstInit: justInstalled, @@ -88,6 +93,7 @@ export default async function loadPlugin(pluginId, justInstalled = false) { resolve(); } catch (error) { + fileIcons.unregisterByPlugin(pluginId); reject(error); } }; diff --git a/tests/unit/fileIconAssets.test.ts b/tests/unit/fileIconAssets.test.ts index f0bc67c581..239e34c77a 100644 --- a/tests/unit/fileIconAssets.test.ts +++ b/tests/unit/fileIconAssets.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import fileIcons, { fileIconApi } from "lib/fileIcons"; +import fileIcons from "lib/fileIcons"; const requests: FakeImage[] = []; class FakeImage { @@ -115,6 +115,10 @@ describe("icon assets", () => { }); it("exposes only the supported plugin surface", () => { - expect(Object.keys(fileIconApi)).toEqual(["register", "icon", "onChange"]); + expect( + Object.keys( + fileIcons.bindPlugin(document.createElement("script"), "test.plugin"), + ), + ).toEqual(["register", "icon", "onChange"]); }); }); diff --git a/tests/unit/fileIconOwnership.test.ts b/tests/unit/fileIconOwnership.test.ts new file mode 100644 index 0000000000..b1a13c6721 --- /dev/null +++ b/tests/unit/fileIconOwnership.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from "vitest"; +import fileIcons from "lib/fileIcons"; + +afterEach(() => fileIcons.resetForTests()); + +it("binds registration to the loading plugin and rejects mismatched ownership", () => { + const script = document.createElement("script"); + const api = fileIcons.bindPlugin(script, "owner.plugin"); + expect(fileIcons.getPluginApi(script)).toBe(api); + const pack = { id: "owner.icons", icons: "file:///icons/", file: "default" }; + expect(() => api.register({ ...pack, pluginId: "typo.plugin" })).toThrow( + /loading plugin 'owner.plugin'/, + ); + expect(fileIcons.list().some((pack) => pack.id === "owner.icons")).toBe( + false, + ); + api.register(pack); + expect( + fileIcons.list().find((pack) => pack.id === "owner.icons")?.pluginId, + ).toBe("owner.plugin"); + fileIcons.use("owner.icons", { persist: false }); + expect( + document.querySelector('style[data-file-icon="owner.icons"]'), + ).not.toBeNull(); + fileIcons.unregisterByPlugin("owner.plugin"); + expect(fileIcons.active().id).toBe("builtin"); + expect( + document.querySelector('style[data-file-icon="owner.icons"]'), + ).toBeNull(); + expect(() => api.register(pack)).toThrow(/unloaded/); +}); + +it("keeps interleaved asynchronous plugin registrations scoped across reloads", async () => { + const first = fileIcons.bindPlugin(document.createElement("script"), "first"); + const second = fileIcons.bindPlugin( + document.createElement("script"), + "second", + ); + await Promise.resolve(); + second.register({ id: "second.icons" }); + first.register({ id: "first.icons", pluginId: "first" }); + fileIcons.unregisterByPlugin("first"); + const replacement = fileIcons.bindPlugin( + document.createElement("script"), + "first", + ); + replacement.register({ id: "first.icons" }); + expect(() => first.register({ id: "first.icons" })).toThrow(/unloaded/); + expect( + fileIcons + .list() + .filter((pack) => pack.available) + .map((pack) => pack.id), + ).toEqual(["builtin", "second.icons", "first.icons"]); +}); + +it("does not offer an unscoped registration API outside plugin execution", () => { + expect(() => fileIcons.getPluginApi(null)).toThrow(/options.fileIcons/); + expect(() => + fileIcons.getPluginApi(document.createElement("script")), + ).toThrow(/plugin main script/); +}); diff --git a/tests/unit/fileTreeRecycling.test.ts b/tests/unit/fileTreeRecycling.test.ts new file mode 100644 index 0000000000..1c9d57477d --- /dev/null +++ b/tests/unit/fileTreeRecycling.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import tag from "html-tag-js"; +import FileTree from "components/fileTree"; + +vi.mock("utils/helpers", () => ({ + default: { + getIconForFile: (name: string) => name, + getIconForFolder: (name: string, options: { expanded: boolean }) => + `${name}-${options.expanded ? "open" : "closed"}`, + sortDir: (entries: unknown[]) => entries, + }, +})); +beforeEach(() => vi.stubGlobal("tag", tag)); +afterEach(() => vi.unstubAllGlobals()); + +it("uses the recycled folder's name and URL for toggles and context actions", async () => { + const getEntries = vi.fn(async () => []); + const onExpandedChange = vi.fn(); + const onContextMenu = vi.fn(); + const tree = new FileTree(document.createElement("div"), { + getEntries, + onExpandedChange, + onContextMenu, + }); + const row = tree.createFolderElement("src", "file:///src"); + await row.expand(); + const originalChild = row.fileTree; + const destroy = vi.spyOn(originalChild, "destroy"); + expect(tree.createFolderElement("assets", "file:///assets", row)).toBe(row); + expect(destroy).toHaveBeenCalledTimes(1); + expect(row.fileTree).toBeNull(); + expect(row.$title.firstElementChild.className).toBe("assets-closed"); + await row.expand(); + expect(getEntries).toHaveBeenLastCalledWith("file:///assets"); + expect(row.$title.firstElementChild.className).toBe("assets-open"); + expect(onExpandedChange).toHaveBeenLastCalledWith("file:///assets", true); + expect(tree.childTrees.has("file:///src")).toBe(false); + expect(tree.childTrees.has("file:///assets")).toBe(true); + await row.collapse(); + expect(row.$title.firstElementChild.className).toBe("assets-closed"); + expect(onExpandedChange).toHaveBeenLastCalledWith("file:///assets", false); + row.$title.dispatchEvent(new Event("contextmenu")); + expect(onContextMenu).toHaveBeenLastCalledWith( + "dir", + "file:///assets", + "assets", + row.$title, + ); + tree.destroy(); +}); + +it("uses recycled file identity for open and context actions", () => { + const onFileClick = vi.fn(); + const onContextMenu = vi.fn(); + const tree = new FileTree(document.createElement("div"), { + onFileClick, + onContextMenu, + }); + const row = tree.createFileElement("a.js", "file:///a.js"); + tree.createFileElement("b.ts", "file:///b.ts", row); + row.click(); + row.dispatchEvent(new Event("contextmenu")); + expect(onFileClick).toHaveBeenCalledWith("file:///b.ts", "b.ts"); + expect(onContextMenu).toHaveBeenCalledWith( + "file", + "file:///b.ts", + "b.ts", + row, + ); + tree.destroy(); +}); From a9e4eb6269dd150e686a8cf5f37c7a1157b1c29f Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:00:27 +0530 Subject: [PATCH 7/9] fix: listeners leak --- src/lib/fileIcons.ts | 26 +++++++++++-- tests/unit/fileIconOwnership.test.ts | 57 +++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/lib/fileIcons.ts b/src/lib/fileIcons.ts index ebcf6fb75d..c028273c35 100644 --- a/src/lib/fileIcons.ts +++ b/src/lib/fileIcons.ts @@ -444,7 +444,10 @@ function lastExtension(name: string): string { } class FileIconRegistry { - #pluginScopes = new Map(); + #pluginScopes = new Map< + string, + { active: boolean; subscriptions: Set<() => void> } + >(); #scriptApis = new WeakMap< HTMLScriptElement, ReturnType @@ -454,7 +457,7 @@ class FileIconRegistry { bindPlugin(script: HTMLScriptElement, pluginId: string) { const previous = this.#pluginScopes.get(pluginId); if (previous) throw new Error(`Plugin '${pluginId}' is already bound`); - const scope = { active: true }; + const scope = { active: true, subscriptions: new Set<() => void>() }; this.#pluginScopes.set(pluginId, scope); const assertActive = () => { if (!scope.active) @@ -473,7 +476,18 @@ class FileIconRegistry { return this.register({ ...pack, pluginId }); }, icon: this.icon.bind(this), - onChange: this.onChange.bind(this), + onChange: (listener: Parameters[0]) => { + assertActive(); + if (typeof listener !== "function") return () => {}; + // Each subscription gets its own callback, even when plugins share a function. + const off = this.onChange((info) => listener(info)); + const unsubscribe = () => { + off(); + scope.subscriptions.delete(unsubscribe); + }; + scope.subscriptions.add(unsubscribe); + return unsubscribe; + }, }); this.#scriptApis.set(script, api); return api; @@ -555,7 +569,11 @@ class FileIconRegistry { unregisterByPlugin(pluginId: string): void { if (!pluginId) return; const scope = this.#pluginScopes.get(pluginId); - if (scope) scope.active = false; + if (scope) { + scope.active = false; + // Removing the active pack emits a change; detach plugin code first. + for (const unsubscribe of scope.subscriptions) unsubscribe(); + } this.#pluginScopes.delete(pluginId); for (const [id, compiled] of [...this.#compiled]) { if (compiled.pluginId === pluginId) this.unregister(id); diff --git a/tests/unit/fileIconOwnership.test.ts b/tests/unit/fileIconOwnership.test.ts index b1a13c6721..032e9d94db 100644 --- a/tests/unit/fileIconOwnership.test.ts +++ b/tests/unit/fileIconOwnership.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { afterEach, expect, it } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import fileIcons from "lib/fileIcons"; afterEach(() => fileIcons.resetForTests()); @@ -61,3 +61,58 @@ it("does not offer an unscoped registration API outside plugin execution", () => fileIcons.getPluginApi(document.createElement("script")), ).toThrow(/plugin main script/); }); + +it("removes plugin listeners before pack teardown emits a change", () => { + const api = fileIcons.bindPlugin(document.createElement("script"), "owner"); + api.register({ id: "owner.icons" }); + fileIcons.use("owner.icons", { persist: false }); + const listener = vi.fn(); + api.onChange(listener); + const internal = vi.fn(); + const offInternal = fileIcons.onChange(internal); + fileIcons.unregisterByPlugin("owner"); + expect(internal).toHaveBeenCalledTimes(1); + expect(listener).not.toHaveBeenCalled(); + fileIcons.use("missing", { persist: false }); + expect(listener).not.toHaveBeenCalled(); + expect(() => api.onChange(listener)).toThrow(/unloaded/); + offInternal(); +}); + +it("cleans listener-only scopes on failed init and isolates reloaded subscriptions", () => { + const listener = vi.fn(); + const old = fileIcons.bindPlugin(document.createElement("script"), "owner"); + const staleUnsubscribe = old.onChange(listener); + // The loader uses this same teardown path when initialization fails. + fileIcons.unregisterByPlugin("owner"); + const current = fileIcons.bindPlugin( + document.createElement("script"), + "owner", + ); + const off = current.onChange(listener); + staleUnsubscribe(); + staleUnsubscribe(); + fileIcons.use("missing", { persist: false }); + expect(listener).toHaveBeenCalledTimes(1); + off(); + off(); + fileIcons.use("builtin", { persist: false }); + expect(listener).toHaveBeenCalledTimes(1); +}); + +it("preserves another plugin's subscription to the same callback", () => { + const listener = vi.fn(); + const first = fileIcons.bindPlugin(document.createElement("script"), "first"); + const second = fileIcons.bindPlugin( + document.createElement("script"), + "second", + ); + first.onChange(listener); + second.onChange(listener); + fileIcons.unregisterByPlugin("first"); + fileIcons.use("missing", { persist: false }); + expect(listener).toHaveBeenCalledTimes(1); + fileIcons.unregisterByPlugin("second"); + fileIcons.use("builtin", { persist: false }); + expect(listener).toHaveBeenCalledTimes(1); +}); From 64b02fc20ee619ef1ef2abcf060370e5ccfd242c Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:50:32 +0530 Subject: [PATCH 8/9] address the overhead of matchExtenstion --- src/lib/fileIcons.ts | 19 ++++++++------- tests/unit/fileIconTheme.test.ts | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/lib/fileIcons.ts b/src/lib/fileIcons.ts index c028273c35..32fdeeaa49 100644 --- a/src/lib/fileIcons.ts +++ b/src/lib/fileIcons.ts @@ -426,21 +426,22 @@ function matchExtension( extensions: Map, ): string | undefined { const lower = name.toLowerCase(); - const parts = lower.split("."); - if (parts.length < 2 || (parts.length === 2 && !parts[0])) return undefined; - for (let i = 1; i < parts.length; i++) { - const ext = parts.slice(i).join("."); - if (ext && extensions.has(ext)) return extensions.get(ext); + let dot = lower.indexOf("."); + // A standalone dotfile has no extension; compound dotfiles still do. + if (dot === 0 && lower.indexOf(".", 1) === -1) return undefined; + for (; dot !== -1; dot = lower.indexOf(".", dot + 1)) { + const ext = lower.slice(dot + 1); + if (!ext) continue; + const iconId = extensions.get(ext); + if (iconId !== undefined) return iconId; } return undefined; } function lastExtension(name: string): string { const lower = name.toLowerCase(); - if (lower.startsWith(".") && lower.indexOf(".", 1) === -1) return ""; - const parts = lower.split("."); - if (parts.length < 2) return ""; - return parts[parts.length - 1] || ""; + const dot = lower.lastIndexOf("."); + return dot > 0 ? lower.slice(dot + 1) : ""; } class FileIconRegistry { diff --git a/tests/unit/fileIconTheme.test.ts b/tests/unit/fileIconTheme.test.ts index 1e085a57e3..6f81cf0973 100644 --- a/tests/unit/fileIconTheme.test.ts +++ b/tests/unit/fileIconTheme.test.ts @@ -313,3 +313,44 @@ describe("theme contract", () => { ).toBe("source"); }); }); + +describe("extension scanning edge cases", () => { + it.each([ + ["button.test.ts", "test"], + ["BUTTON.TEST.TS", "test"], + ["button.other.ts", "ts"], + [".config.test.ts", "test"], + [".test.ts", "test"], + ["button..ts", "ts"], + [".ts", "plain"], + ["README", "plain"], + ["button.ts.", "plain"], + [".", "plain"], + ["..", "plain"], + ["", "plain"], + ])("resolves %j to %s", (name, expected) => { + fileIcons.register({ + id: "scan", + pluginId: "test.plugin", + icons: { + test: { className: "test" }, + ts: { className: "ts" }, + plain: { className: "plain" }, + }, + fileExtensions: { "test.ts": "test", ts: "ts" }, + file: "plain", + }); + fileIcons.use("scan", { persist: false }); + expect(fileIcons.icon(name)).toBe(expected); + }); + + it.each([ + ["file.UNLISTED", "unlisted"], + ["file..UNLISTED", "unlisted"], + [".UNLISTED", "default"], + ["file.UNLISTED.", "default"], + ["extensionless", "default"], + ])("keeps the built-in last-extension fallback for %j", (name, expected) => { + expect(fileIcons.resolve(name).iconId).toBe(expected); + }); +}); From 81907c1d7e04642d1c7d65f691dda2a475d95044 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:47:34 +0530 Subject: [PATCH 9/9] format --- src/lang/hu-hu.json | 2 +- src/lang/id-id.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lang/hu-hu.json b/src/lang/hu-hu.json index 2714aa920a..4e1b5aa789 100644 --- a/src/lang/hu-hu.json +++ b/src/lang/hu-hu.json @@ -882,7 +882,7 @@ "wrap-indent-indent": "Behúzás (+1 szint)", "wrap-indent-deep": "Mély behúzás (+2 szint)", "settings-info-editor-wrapping-indent": "Válassza ki, hogy a tördelt szövegek a képernyő bal szélétől kezdődjenek-e, az eredeti sor behúzását kövessék-e, vagy még jobban legyenek-e behúzva. Minden további behúzási szint a tabulátor méretét veszi alapul.", - "icon pack": "Icon pack", + "icon pack": "Icon pack", "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", "unavailable": "unavailable" } diff --git a/src/lang/id-id.json b/src/lang/id-id.json index 660d642961..f7dceb77f1 100644 --- a/src/lang/id-id.json +++ b/src/lang/id-id.json @@ -882,7 +882,7 @@ "wrap-indent-indent": "Indentasi (+1 level)", "wrap-indent-deep": "Indentasi dalam (+2 level)", "settings-info-editor-wrapping-indent": "Pilih apakah teks terbungkus dimulai dari tepi kiri, mengikuti indentasi baris asli, atau di-indentasi lebih jauh. Setiap level tambahan menggunakan ukuran tab Anda.", - "icon pack": "Icon pack", + "icon pack": "Icon pack", "settings-info-icon-pack": "Choose the icons used for files and folders in the explorer and file lists across the app.", "unavailable": "unavailable" }