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