diff --git a/src/components/fileTree/index.js b/src/components/fileTree/index.js
index 0250b1c01..caf20bda0 100644
--- a/src/components/fileTree/index.js
+++ b/src/components/fileTree/index.js
@@ -127,6 +127,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")) {
@@ -137,6 +143,7 @@ export default class FileTree {
this.childTrees.delete(recycledEl._folderUrl);
}
recycledEl.$ul.innerHTML = "";
+ recycledEl.$ul._fileTree = null;
}
recycledEl._folderUrl = url;
@@ -149,7 +156,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,
@@ -166,15 +175,20 @@ 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
$wrapper.classList.add("hidden");
+ $indicator.className = helpers.getIconForFolder(name, {
+ expanded: false,
+ });
if (childTree) {
childTree.destroy();
@@ -186,6 +200,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
@@ -211,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
@@ -225,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(),
@@ -284,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/components/referencesPanel/utils.js b/src/components/referencesPanel/utils.js
index d02de6ca5..a7cec957c 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 25e2ce8ae..3357206c6 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 8aeb1c6d6..c34567f71 100644
--- a/src/dialogs/select.js
+++ b/src/dialogs/select.js
@@ -2,6 +2,7 @@ import Checkbox from "components/checkbox";
import tile from "components/tile";
import DOMPurify from "dompurify";
import actionStack from "lib/actionStack";
+import fileIcons from "lib/fileIcons";
import restoreTheme from "lib/restoreTheme";
/**
@@ -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/ar-ye.json b/src/lang/ar-ye.json
index eb5a33428..6939fd6b6 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 b59491ea8..9561a723f 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 28d101f00..23221edf8 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 2646d4347..52596946d 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 bf78e973d..5ef81148c 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 0af439c2d..b3d998508 100644
--- a/src/lang/en-us.json
+++ b/src/lang/en-us.json
@@ -179,6 +179,9 @@
"light": "Light",
"dark": "Dark",
"file browser": "File Browser",
+ "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",
"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/lang/es-sv.json b/src/lang/es-sv.json
index 97e96193a..1942bb00a 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 103ae9920..ec0b418df 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 e0b76ca2e..35b7a716c 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 c04224087..f16dae7b2 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 83e458519..4e1b5aa78 100644
--- a/src/lang/hu-hu.json
+++ b/src/lang/hu-hu.json
@@ -881,5 +881,8 @@
"wrap-indent-same": "Azonos",
"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."
+ "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",
+ "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 75b52a84e..f7dceb77f 100644
--- a/src/lang/id-id.json
+++ b/src/lang/id-id.json
@@ -881,5 +881,8 @@
"wrap-indent-same": "Sama",
"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."
+ "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",
+ "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 f643241de..7873fb8b6 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 e9f47b3b1..8a1db6e17 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 5cced6fc4..19ddbba94 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 f80382673..03ec6d495 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 f33b9773c..fd76716b4 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 e9d379d27..a0de1f1e1 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 04cf2f787..4b1af01ee 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 f14e6b82b..74364aff0 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 5fb0e344b..d1f72c1e8 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 5aafd67b6..e1729da06 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 64f45d640..6a7516d6c 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 60912d7a6..70ac968a6 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 162849447..0d61d611b 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 3cfed0807..bb7b2ac74 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 39898581a..de2f19aae 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 84f79fc2b..43f8a786a 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 04c6f91dc..8ecb20a89 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 54f3850ff..ce61c46ac 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 c945e84c8..eafd3a041 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 86399804b..4f2d88a12 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 01f2957f1..679abc6d3 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/lib/acode.js b/src/lib/acode.js
index 668a5356d..e5d57e2bd 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";
@@ -558,6 +559,8 @@ class Acode {
}
require(module) {
+ if (module.toLowerCase() === "fileicons")
+ return fileIcons.getPluginApi(document.currentScript);
return this.#modules[module.toLowerCase()];
}
@@ -788,6 +791,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 000000000..32fdeeaa4
--- /dev/null
+++ b/src/lib/fileIcons.ts
@@ -0,0 +1,1103 @@
+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 =
+ | "fileName"
+ | "fileExtension"
+ | "languageId"
+ | "folderName"
+ | "default";
+
+export type IconDefinition =
+ | { src: string; monochrome?: boolean; className?: never }
+ | { className: string; src?: never; monochrome?: never };
+
+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;
+ schemaVersion?: number;
+ pluginId: string;
+ icons?: string | Record;
+}
+
+export interface IconResource {
+ kind?: IconKind;
+ name: string;
+ languageId?: string;
+ expanded?: boolean;
+ isRoot?: boolean;
+}
+
+export interface IconHandle {
+ className: string;
+ iconId: string;
+ source: IconMatchSource;
+ kind: IconKind;
+ themeId: string;
+ expanded?: boolean;
+}
+
+export interface IconThemeInfo {
+ id: string;
+ name: string;
+ available: boolean;
+ pluginId: string | null;
+}
+
+export interface ActiveIconTheme {
+ id: string;
+ preferredId: string;
+ name: string;
+ available: boolean;
+}
+
+interface CompiledTheme {
+ id: string;
+ name: string;
+ pluginId: string | null;
+ schemaVersion: number;
+ icons: Map;
+ fileNames: Map;
+ fileNamesCi: Map;
+ fileExtensions: Map;
+ languageIds: Map;
+ folderNames: Map;
+ folderNamesExpanded: Map;
+ defaults: Required;
+}
+
+interface IconThemeSettings {
+ value?: { iconTheme?: string };
+ on?: (event: string, callback: (value: unknown) => void) => void;
+ update?: (showToast?: boolean) => void;
+}
+
+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 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";
+}
+
+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`);
+ }
+}
+
+function iconIdFromAssoc(value: unknown): string {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new Error("Association values must be non-empty icon ids");
+ }
+ return value;
+}
+
+type ThemeInput = Omit & { pluginId?: string };
+
+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] = { 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);
+ }
+ 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 ?? "");
+ if (!value) throw new Error(`Empty ${kind} association`);
+ 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;
+}
+
+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",
+ );
+ 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): string {
+ const encode = (value: string) =>
+ Array.from(value, (char) => char.codePointAt(0)!.toString(16)).join("_");
+ return `file-icon--${encode(themeId)}--${encode(iconId)}`;
+}
+
+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 (!def || typeof def !== "object" || Array.isArray(def)) {
+ 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;
+ 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 !== undefined &&
+ (typeof rec.monochrome !== "boolean" || !rec.src)
+ ) {
+ throw new Error(
+ `icons.${id}.monochrome requires src and must be a boolean`,
+ );
+ }
+ return rec.src
+ ? { src: rec.src.trim(), monochrome: rec.monochrome }
+ : { className: rec.className!.trim() };
+}
+
+function compileTheme(input: ThemeInput): 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 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, "fileName");
+ for (const [key, iconId] of fileNames) {
+ const lower = key.toLowerCase();
+ 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,
+ "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,
+ name: String(theme.name || id),
+ pluginId: theme.pluginId || null,
+ schemaVersion,
+ icons,
+ fileNames,
+ fileNamesCi,
+ fileExtensions,
+ languageIds,
+ folderNames,
+ folderNamesExpanded,
+ defaults: {
+ file: theme.file || "file",
+ folder: theme.folder || "folder",
+ folderExpanded: theme.folderExpanded || theme.folder || "folder",
+ rootFolder: theme.rootFolder || theme.folder || "folder",
+ rootFolderExpanded:
+ theme.rootFolderExpanded ||
+ theme.rootFolder ||
+ theme.folderExpanded ||
+ theme.folder ||
+ "folder",
+ },
+ };
+}
+
+function matchExtension(
+ name: string,
+ extensions: Map,
+): string | undefined {
+ const lower = name.toLowerCase();
+ 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();
+ const dot = lower.lastIndexOf(".");
+ return dot > 0 ? lower.slice(dot + 1) : "";
+}
+
+class FileIconRegistry {
+ #pluginScopes = new Map<
+ string,
+ { active: boolean; subscriptions: Set<() => void> }
+ >();
+ #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, subscriptions: new Set<() => void>() };
+ 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: (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;
+ }
+
+ 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
+ >();
+ #activeId = BUILTIN_THEME_ID;
+ #preferredId = BUILTIN_THEME_ID;
+ #assetStates = new Map();
+ #assetRefreshQueued = false;
+ #settings: IconThemeSettings | null = null;
+
+ constructor() {
+ const builtin = compileTheme(createBuiltinTheme());
+ this.#compiled.set(builtin.id, builtin);
+ 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 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)
+ 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();
+ }
+ return {
+ dispose: () => {
+ if (this.#compiled.get(compiled.id) === compiled)
+ this.unregister(compiled.id);
+ },
+ };
+ }
+
+ unregister(id: string): boolean {
+ if (id === BUILTIN_THEME_ID) return false;
+ if (!this.#compiled.has(id)) return false;
+ this.#compiled.delete(id);
+ this.#removeThemeStyles(id);
+ if (this.#activeId === id) {
+ this.#activate(BUILTIN_THEME_ID);
+ this.#emitChange();
+ }
+ return true;
+ }
+
+ unregisterByPlugin(pluginId: string): void {
+ if (!pluginId) return;
+ const scope = this.#pluginScopes.get(pluginId);
+ 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);
+ }
+ }
+
+ list(): IconThemeInfo[] {
+ const list: IconThemeInfo[] = [];
+ for (const compiled of this.#compiled.values()) {
+ list.push({
+ id: compiled.id,
+ name: compiled.name,
+ available: true,
+ pluginId: compiled.pluginId,
+ });
+ }
+ if (this.#preferredId && !this.#compiled.has(this.#preferredId)) {
+ list.push({
+ id: this.#preferredId,
+ name: this.#preferredId,
+ available: false,
+ pluginId: null,
+ });
+ }
+ return list;
+ }
+
+ active(): ActiveIconTheme {
+ const compiled = this.#compiled.get(this.#activeId);
+ return {
+ id: this.#activeId,
+ preferredId: this.#preferredId,
+ name: compiled?.name || this.#activeId,
+ available: this.#compiled.has(this.#preferredId),
+ };
+ }
+
+ 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;
+ if (activeChanged) this.#activate(resolved);
+ if (persist) this.#persistPreferred(next);
+ if (preferredChanged || activeChanged) this.#emitChange();
+ return this.active();
+ }
+
+ 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;
+ 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;
+ }
+
+ onChange(
+ listener: (info: { activeId: string; preferredId: string }) => void,
+ ): () => void {
+ if (typeof listener !== "function") return () => {};
+ this.#listeners.add(listener);
+ return () => this.#listeners.delete(listener);
+ }
+
+ refreshRenderedIcons(root: ParentNode | null = getDocument()): void {
+ if (!root) return;
+
+ const apply = () => {
+ 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(
+ $tile,
+ this.icon({ kind: "file", name: $tile.dataset.name || "" }),
+ );
+ }
+
+ for (const $tile of root.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",
+ }),
+ );
+ }
+
+ if (root === getDocument()) this.#refreshEditorTabs();
+ };
+
+ apply();
+ }
+
+ 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);
+ }
+ this.#listeners.clear();
+ this.#preferredId = BUILTIN_THEME_ID;
+ this.#activeId = BUILTIN_THEME_ID;
+ this.#settings = null;
+ }
+
+ #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 {
+ 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 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,
+ );
+ }
+
+ languageId ||= inferLanguageId(name);
+ 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 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);
+ }
+
+ #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 &&
+ 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"
+ ? 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?.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);
+ 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) {
+ if (def.src)
+ rules.push(
+ cssForSrc(
+ assetClassName(compiled.id, iconId),
+ def.src,
+ !!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 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;
+}
+
+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 000000000..bc2ed6e67
--- /dev/null
+++ b/src/lib/fileIconsBuiltin.ts
@@ -0,0 +1,232 @@
+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,
+ name: "Builtin",
+ schemaVersion: SCHEMA_VERSION,
+ fileNames: createFileNames(),
+ fileExtensions: createFileExtensions(),
+ file: "file",
+ folder: "folder",
+ folderExpanded: "folder",
+ rootFolder: "folder",
+ rootFolderExpanded: "folder",
+ };
+}
diff --git a/src/lib/loadPlugin.js b/src/lib/loadPlugin.js
index 2272163ec..e719965bf 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/src/lib/openFolder.js b/src/lib/openFolder.js
index 927606a92..fa300d0ce 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/recents.js b/src/lib/recents.js
index 1f583a8a0..eda542edc 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/lib/settings.js b/src/lib/settings.js
index fd6f3cd79..de8d3b18b 100644
--- a/src/lib/settings.js
+++ b/src/lib/settings.js
@@ -219,6 +219,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 87b6f9a44..3f412ba42 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 a7f912f74..f6f46e65d 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,27 @@ export default function otherSettings() {
category: categories.fonts,
chevron: true,
},
+ {
+ key: "iconTheme",
+ text: strings["icon pack"],
+ value: values.iconTheme || "builtin",
+ get select() {
+ return fileIcons
+ .list()
+ .map((theme) => [
+ theme.id,
+ theme.available === false
+ ? `${theme.name} (${strings.unavailable || "unavailable"})`
+ : theme.name,
+ ]);
+ },
+ valueText: (value) => {
+ const theme = fileIcons.list().find((entry) => entry.id === value);
+ return theme?.name || value || "Builtin";
+ },
+ info: strings["settings-info-icon-pack"],
+ category: categories.interface,
+ },
{
key: "rememberFiles",
text: strings["remember opened files"],
diff --git a/src/settings/formatterSettings.js b/src/settings/formatterSettings.js
index 512510d6d..604caec67 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 1c9e900ec..4baca7cec 100644
--- a/src/sidebarApps/files/index.js
+++ b/src/sidebarApps/files/index.js
@@ -1,5 +1,6 @@
import "./style.scss";
import Sidebar from "components/sidebar";
+import fileIcons from "lib/fileIcons";
import settings from "lib/settings";
/**@type {HTMLElement} */
@@ -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 0ddc75453..4d755f358 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() {
@@ -165,7 +168,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 d2b97f532..e48e94bea 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.icon({ 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.icon({
+ 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/fileIconAssets.test.ts b/tests/unit/fileIconAssets.test.ts
new file mode 100644
index 000000000..239e34c77
--- /dev/null
+++ b/tests/unit/fileIconAssets.test.ts
@@ -0,0 +1,124 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import fileIcons 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(
+ 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 000000000..032e9d94d
--- /dev/null
+++ b/tests/unit/fileIconOwnership.test.ts
@@ -0,0 +1,118 @@
+// @vitest-environment happy-dom
+import { afterEach, expect, it, vi } 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/);
+});
+
+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);
+});
diff --git a/tests/unit/fileIconSidebar.test.ts b/tests/unit/fileIconSidebar.test.ts
new file mode 100644
index 000000000..c83bfe0fa
--- /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
new file mode 100644
index 000000000..6f81cf097
--- /dev/null
+++ b/tests/unit/fileIconTheme.test.ts
@@ -0,0 +1,356 @@
+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({
+ pluginId: "test.plugin",
+ 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({
+ pluginId: "test.plugin",
+ 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.resolve("app.js").iconId).toBe("javascript");
+ expect(fileIcons.resolve({ kind: "folder", name: "src" }).iconId).toBe(
+ "folder-src",
+ );
+ expect(
+ 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", () => {
+ 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("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" } },
+ fileExtensions: { js: "js" },
+ });
+ fileIcons.use("stable-icons", { persist: false });
+
+ expect(() =>
+ fileIcons.register({
+ pluginId: "test.plugin",
+ id: "stable-icons",
+ fileExtensions: { js: "js" },
+ icons: { js: { src: "javascript:alert(1)" } },
+ }),
+ ).toThrow(/Unsafe/);
+
+ expect(fileIcons.icon("app.js")).toBe("icon stable-js");
+ });
+
+ 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({
+ ...theme(),
+ folderExpanded: "open",
+ rootFolder: "closed",
+ });
+ 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");
+ });
+});
+
+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);
+ });
+});
diff --git a/tests/unit/fileTreeRecycling.test.ts b/tests/unit/fileTreeRecycling.test.ts
new file mode 100644
index 000000000..1c9d57477
--- /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();
+});