feat(sidebar): show empty folders and create folders from the sidebar (#999)

Empty folders (tracked in the scan-recorded all_folders list, same source
the move/download destination picker uses) can now be surfaced in the
folder sidebar via a view-options toggle, dimmed when their subtree holds
no models. Folders can be created directly from the sidebar through a new
POST /api/lm/{prefix}/create-folder endpoint with library-root
containment checks; the scanner records the new directory incrementally
so the tree reflects it without a rescan.

The sidebar header moves its view toggles (tree/list, recursive, empty
folders) into a "..." menu to fit the new create-folder button.
This commit is contained in:
Will Miao
2026-09-15 15:14:56 +08:00
parent 2ceb1e2850
commit 9734df15b4
24 changed files with 1260 additions and 173 deletions
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden", "hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen", "showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet", "sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"switchToListView": "Zur Listenansicht wechseln", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "Zur Baumansicht wechseln", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "Unterordner einbeziehen", "recursiveOn": "Unterordner einbeziehen",
"recursiveOff": "Nur aktueller Ordner", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen" "dragHint": "Elemente hierher ziehen, um Ordner zu erstellen",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen", "label": "Auf Updates in diesem Ordner prüfen",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "Root", "modelRoot": "Root",
"collapseAll": "Collapse All Folders", "collapseAll": "Collapse All Folders",
"collapseAllDisabled": "Not available in list view",
"hideOnThisPage": "Hide sidebar on this page", "hideOnThisPage": "Hide sidebar on this page",
"showSidebar": "Show sidebar", "showSidebar": "Show sidebar",
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page", "sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
"switchToListView": "Switch to List View", "viewOptions": "View options",
"switchToTreeView": "Switch to Tree View", "treeView": "Tree view",
"listView": "List view",
"recursiveOn": "Include subfolders", "recursiveOn": "Include subfolders",
"recursiveOff": "Current folder only", "createFolder": "New folder",
"recursiveUnavailable": "Recursive search is available in tree view only", "newSubfolder": "New subfolder",
"collapseAllDisabled": "Not available in list view", "showEmptyFolders": "Show empty folders",
"createFolderResult": {
"success": "Folder \"{name}\" created",
"failed": "Failed to create folder: {message}",
"unsupported": "Folder creation is not supported on this page",
"noRoot": "No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.", "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.", "moveUnsupported": "Move is not supported for this item.",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "No folders found", "noFolders": "No folders found",
"dragHint": "Drag items here to create folders" "dragHint": "Drag items here to create folders",
"createHint": "Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Check for updates in this folder", "label": "Check for updates in this folder",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "Ocultar barra lateral en esta página", "hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral", "showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}", "sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"switchToListView": "Cambiar a vista de lista", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "Cambiar a vista de árbol", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "Incluir subcarpetas", "recursiveOn": "Incluir subcarpetas",
"recursiveOff": "Solo carpeta actual", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "No disponible en vista de lista", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.", "moveUnsupported": "El movimiento no es compatible con este elemento.",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "No se encontraron carpetas", "noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas" "dragHint": "Arrastra elementos aquí para crear carpetas",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta", "label": "Buscar actualizaciones en esta carpeta",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "Masquer la barre latérale sur cette page", "hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale", "showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}", "sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"switchToListView": "Passer en vue liste", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "Passer en vue arborescence", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "Inclure les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",
"recursiveOff": "Dossier actuel uniquement", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "Non disponible en vue liste", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.", "moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "Aucun dossier trouvé", "noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers" "dragHint": "Faites glisser des éléments ici pour créer des dossiers",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier", "label": "Vérifier les mises à jour dans ce dossier",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "הסתר סרגל צד בדף זה", "hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד", "showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}", "sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"switchToListView": "עבור לתצוגת רשימה", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "תצוגת עץ", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "כלול תיקיות משנה", "recursiveOn": "כלול תיקיות משנה",
"recursiveOff": "רק התיקייה הנוכחית", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "לא נמצאו תיקיות", "noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות" "dragHint": "גרור פריטים לכאן כדי ליצור תיקיות",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו", "label": "בדוק עדכונים בתיקייה זו",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "このページでサイドバーを非表示", "hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示", "showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています", "sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"switchToListView": "リストビューに切り替え", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "ツリー表示に切り替え", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "サブフォルダーを含める", "recursiveOn": "サブフォルダーを含める",
"recursiveOff": "現在のフォルダーのみ", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "リストビューでは利用できません", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。", "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。", "moveUnsupported": "この項目の移動はサポートされていません。",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "フォルダが見つかりません", "noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します" "dragHint": "ここへアイテムをドラッグしてフォルダを作成します",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "このフォルダのアップデートを確認", "label": "このフォルダのアップデートを確認",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기", "hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시", "showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다", "sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"switchToListView": "목록 보기로 전환", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "트리 보기로 전환", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "하위 폴더 포함", "recursiveOn": "하위 폴더 포함",
"recursiveOff": "현재 폴더만", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "폴더를 찾을 수 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다" "dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인", "label": "이 폴더의 업데이트 확인",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "Скрыть боковую панель на этой странице", "hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель", "showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}", "sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"switchToListView": "Переключить на вид списка", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "Переключить на древовидный вид", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "Включать вложенные папки", "recursiveOn": "Включать вложенные папки",
"recursiveOff": "Только текущая папка", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "Недоступно в виде списка", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.", "moveUnsupported": "Перемещение этого элемента не поддерживается.",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "Папки не найдены", "noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки" "dragHint": "Перетащите элементы сюда, чтобы создать папки",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Проверить обновления в этой папке", "label": "Проверить обновления в этой папке",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "隐藏此页面侧边栏", "hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏", "showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏", "sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"switchToListView": "切换到列表视图", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "切换到树状视图", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "包含子文件夹", "recursiveOn": "包含子文件夹",
"recursiveOff": "仅当前文件夹", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "仅在树形视图中可使用递归搜索", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "列表视图下不可用", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。", "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "此条目不支持移动。", "moveUnsupported": "此条目不支持移动。",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "未找到文件夹", "noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹" "dragHint": "拖拽项目到此处以创建文件夹",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "检查此文件夹的更新", "label": "检查此文件夹的更新",
+15 -6
View File
@@ -1246,15 +1246,23 @@
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
"hideOnThisPage": "隱藏此頁面側邊欄", "hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄", "showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏", "sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"switchToListView": "切換至列表檢視", "viewOptions": "[TODO: Translate] View options",
"switchToTreeView": "切換到樹狀檢視", "treeView": "[TODO: Translate] Tree view",
"listView": "[TODO: Translate] List view",
"recursiveOn": "包含子資料夾", "recursiveOn": "包含子資料夾",
"recursiveOff": "僅目前資料夾", "createFolder": "[TODO: Translate] New folder",
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "newSubfolder": "[TODO: Translate] New subfolder",
"collapseAllDisabled": "列表檢視下不可用", "showEmptyFolders": "[TODO: Translate] Show empty folders",
"createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created",
"failed": "[TODO: Translate] Failed to create folder: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page",
"noRoot": "[TODO: Translate] No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。", "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "此項目不支援移動。", "moveUnsupported": "此項目不支援移動。",
@@ -1267,7 +1275,8 @@
}, },
"empty": { "empty": {
"noFolders": "未找到資料夾", "noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾" "dragHint": "將項目拖到此處以建立資料夾",
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "檢查此資料夾的更新", "label": "檢查此資料夾的更新",
+21
View File
@@ -2479,6 +2479,26 @@ class ModelMoveHandler:
self._move_service = move_service self._move_service = move_service
self._logger = logger self._logger = logger
async def create_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
result = await self._move_service.create_folder(folder_path)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error creating folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_model(self, request: web.Request) -> web.Response: async def move_model(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -3429,6 +3449,7 @@ class ModelHandlerSet:
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
"move_model": self.move.move_model, "move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk, "move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder,
"auto_organize_models": self.auto_organize.auto_organize_models, "auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"get_model_notes": self.query.get_model_notes, "get_model_notes": self.query.get_model_notes,
+1
View File
@@ -40,6 +40,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"), RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"),
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"), RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition( RouteDefinition(
+52
View File
@@ -484,6 +484,58 @@ class ModelMoveService:
self.scanner = scanner self.scanner = scanner
self.model_type = model_type self.model_type = model_type
async def create_folder(self, folder_path: str) -> Dict[str, Any]:
"""Create a directory inside the model library roots.
Args:
folder_path: Absolute path of the directory to create (business
path symlinks are not resolved)
Returns:
Dictionary with success flag, the created path and the
library-relative folder name used by folder trees.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
already_exists = os.path.isdir(absolute_path)
os.makedirs(absolute_path, exist_ok=True)
relative_folder = self._calculate_relative_folder(absolute_path)
if relative_folder:
await self.scanner.add_known_folder(relative_folder)
return {
"success": True,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
"created": not already_exists,
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error creating folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
def _calculate_relative_folder(self, absolute_path: str) -> str:
"""Return the library-relative folder for an absolute directory path."""
normalized = os.path.abspath(absolute_path)
for root in self.scanner.get_model_roots():
abs_root = os.path.abspath(root)
try:
rel = os.path.relpath(normalized, abs_root)
except ValueError:
continue
if rel == ".":
return ""
if not rel.startswith(".."):
return rel.replace(os.sep, "/")
return ""
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]: async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file """Move a single model file
+28
View File
@@ -1477,6 +1477,34 @@ class ModelScanner:
return sorted(folders, key=lambda x: x.lower()) return sorted(folders, key=lambda x: x.lower())
async def add_known_folder(self, folder: str) -> None:
"""Record a folder (and its parents) in the known folder list.
Called when a directory is created between scans (e.g. via the
create-folder API) so folder trees reflect it immediately without
waiting for the next reconciliation. When ``all_folders`` has not
been recorded yet (legacy snapshot), this is a no-op the scheduled
backfill walk discovers the directory from disk instead.
"""
normalized = folder.replace("\\", "/").strip("/")
parts = [part for part in normalized.split("/") if part]
if not parts:
return
cache = self._cache
if cache is None:
return
recorded = getattr(cache, "all_folders", None)
if recorded is None:
return
known = set(recorded)
for i in range(1, len(parts) + 1):
known.add("/".join(parts[:i]))
updated = sorted(known, key=lambda x: x.lower())
if updated != list(recorded):
cache.all_folders = updated
await self._persist_current_cache()
self.bump_cache_version()
def _schedule_all_folders_backfill(self) -> None: def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running.""" """Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running: if self._all_folders_backfill_running:
+18 -4
View File
@@ -228,6 +228,18 @@
border-left-color: var(--lora-accent); border-left-color: var(--lora-accent);
} }
/* Empty folders (no models) shown when the empty-folders toggle is on */
.sidebar-tree-node-content.empty .sidebar-tree-folder-name,
.sidebar-node-content.empty .sidebar-folder-name {
opacity: 0.55;
font-style: italic;
}
.sidebar-tree-node-content.empty .sidebar-tree-folder-icon,
.sidebar-node-content.empty .sidebar-folder-icon {
opacity: 0.45;
}
.sidebar-tree-node-content.drop-target .sidebar-tree-folder-icon, .sidebar-tree-node-content.drop-target .sidebar-tree-folder-icon,
.sidebar-node-content.drop-target .sidebar-folder-icon { .sidebar-node-content.drop-target .sidebar-folder-icon {
color: var(--lora-accent); color: var(--lora-accent);
@@ -666,10 +678,11 @@
/* Create folder input container */ /* Create folder input container */
.sidebar-create-folder-input-container { .sidebar-create-folder-input-container {
position: absolute; /* Sticky footer inside the scroll container: always visible at the
bottom: 16px; bottom of the viewport regardless of tree scroll position */
left: 16px; position: sticky;
right: 16px; bottom: 8px;
margin: 8px 16px 0;
padding: 12px; padding: 12px;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -703,6 +716,7 @@
.sidebar-create-folder-input { .sidebar-create-folder-input {
flex: 1; flex: 1;
min-width: 0; /* allow the input to shrink below its intrinsic width */
padding: 6px 10px; padding: 6px 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
+5
View File
@@ -22,6 +22,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: true, supportsLetterFilter: true,
supportsBulkOperations: true, supportsBulkOperations: true,
supportsMove: true, supportsMove: true,
supportsFolderManagement: true,
templateName: 'loras.html' templateName: 'loras.html'
}, },
[MODEL_TYPES.CHECKPOINT]: { [MODEL_TYPES.CHECKPOINT]: {
@@ -31,6 +32,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: false, supportsLetterFilter: false,
supportsBulkOperations: true, supportsBulkOperations: true,
supportsMove: true, supportsMove: true,
supportsFolderManagement: true,
templateName: 'checkpoints.html' templateName: 'checkpoints.html'
}, },
[MODEL_TYPES.EMBEDDING]: { [MODEL_TYPES.EMBEDDING]: {
@@ -40,6 +42,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: true, supportsLetterFilter: true,
supportsBulkOperations: true, supportsBulkOperations: true,
supportsMove: true, supportsMove: true,
supportsFolderManagement: true,
templateName: 'embeddings.html' templateName: 'embeddings.html'
}, },
[MODEL_TYPES.OTHER]: { [MODEL_TYPES.OTHER]: {
@@ -49,6 +52,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: false, supportsLetterFilter: false,
supportsBulkOperations: true, supportsBulkOperations: true,
supportsMove: true, supportsMove: true,
supportsFolderManagement: true,
templateName: 'other.html' templateName: 'other.html'
} }
}; };
@@ -79,6 +83,7 @@ export function getApiEndpoints(modelType) {
// Move operations (now common for all model types that support move) // Move operations (now common for all model types that support move)
moveModel: `/api/lm/${modelType}/move_model`, moveModel: `/api/lm/${modelType}/move_model`,
moveBulk: `/api/lm/${modelType}/move_models_bulk`, moveBulk: `/api/lm/${modelType}/move_models_bulk`,
createFolder: `/api/lm/${modelType}/create-folder`,
// CivitAI integration // CivitAI integration
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
+24 -2
View File
@@ -1295,9 +1295,13 @@ export class BaseModelApiClient {
} }
} }
async fetchModelFolders() { async fetchModelFolders(options = {}) {
try { try {
const response = await fetch(this.apiConfig.endpoints.folders); const { includeEmpty = false } = options || {};
const url = includeEmpty
? `${this.apiConfig.endpoints.folders}?include_empty=1`
: this.apiConfig.endpoints.folders;
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`); throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`);
} }
@@ -1308,6 +1312,24 @@ export class BaseModelApiClient {
} }
} }
async createFolder(folderPath) {
const response = await fetch(this.apiConfig.endpoints.createFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
throw new Error(result.error || `Failed to create folder`);
}
return result;
}
async fetchUnifiedFolderTree(options = {}) { async fetchUnifiedFolderTree(options = {}) {
try { try {
const { includeEmpty = false } = options; const { includeEmpty = false } = options;
+333 -92
View File
@@ -44,6 +44,9 @@ export class SidebarManager {
this.initializationPromise = null; this.initializationPromise = null;
this.isCreatingFolder = false; this.isCreatingFolder = false;
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态 this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
this.showEmptyFolders = false;
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
this._createFolderBasePath = null;
// Bind methods // Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this); this.handleTreeClick = this.handleTreeClick.bind(this);
@@ -56,6 +59,9 @@ export class SidebarManager {
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this); this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
this.handleFolderListClick = this.handleFolderListClick.bind(this); this.handleFolderListClick = this.handleFolderListClick.bind(this);
this.handleRecursiveToggle = this.handleRecursiveToggle.bind(this); this.handleRecursiveToggle = this.handleRecursiveToggle.bind(this);
this.handleEmptyFoldersToggle = this.handleEmptyFoldersToggle.bind(this);
this.handleCreateFolderButton = this.handleCreateFolderButton.bind(this);
this.handleViewOptionsButton = this.handleViewOptionsButton.bind(this);
this.handleCardDragStart = this.handleCardDragStart.bind(this); this.handleCardDragStart = this.handleCardDragStart.bind(this);
this.handleCardDragEnd = this.handleCardDragEnd.bind(this); this.handleCardDragEnd = this.handleCardDragEnd.bind(this);
this.handleFolderDragEnter = this.handleFolderDragEnter.bind(this); this.handleFolderDragEnter = this.handleFolderDragEnter.bind(this);
@@ -138,6 +144,9 @@ export class SidebarManager {
this.apiClient = null; this.apiClient = null;
this.isInitialized = false; this.isInitialized = false;
this.recursiveSearchEnabled = true; this.recursiveSearchEnabled = true;
this.showEmptyFolders = false;
this.nonEmptyFolders = null;
this._createFolderBasePath = null;
// Reset container margin // Reset container margin
const container = document.querySelector('.container'); const container = document.querySelector('.container');
@@ -153,16 +162,10 @@ export class SidebarManager {
} }
removeEventHandlers() { removeEventHandlers() {
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
const folderTree = document.getElementById('sidebarFolderTree'); const folderTree = document.getElementById('sidebarFolderTree');
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav'); const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
const sidebarHeader = document.getElementById('sidebarHeader'); const sidebarHeader = document.getElementById('sidebarHeader');
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
if (collapseAllBtn) {
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
}
if (folderTree) { if (folderTree) {
folderTree.removeEventListener('click', this.handleTreeClick); folderTree.removeEventListener('click', this.handleTreeClick);
folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu); folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu);
@@ -181,11 +184,20 @@ export class SidebarManager {
// Remove resize event handler // Remove resize event handler
window.removeEventListener('resize', this.updateContainerMargin); window.removeEventListener('resize', this.updateContainerMargin);
if (displayModeToggleBtn) { const viewOptionsBtn = document.getElementById('sidebarViewOptions');
displayModeToggleBtn.removeEventListener('click', this.handleDisplayModeToggle); if (viewOptionsBtn) {
viewOptionsBtn.removeEventListener('click', this.handleViewOptionsButton);
} }
if (recursiveToggleBtn) { this._closeViewOptionsMenu();
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
const createFolderBtn = document.getElementById('sidebarCreateFolder');
if (createFolderBtn) {
createFolderBtn.removeEventListener('click', this.handleCreateFolderButton);
}
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
if (collapseAllBtn) {
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
} }
const hideToggle = document.getElementById('sidebarHideToggle'); const hideToggle = document.getElementById('sidebarHideToggle');
@@ -773,16 +785,20 @@ export class SidebarManager {
} }
} }
showCreateFolderInput() { showCreateFolderInput(basePath = null) {
console.log('[SidebarManager] showCreateFolderInput called'); console.log('[SidebarManager] showCreateFolderInput called');
this.isCreatingFolder = true; this.isCreatingFolder = true;
// The folder is created under the given base path; falls back to the
// currently selected folder (drag-and-drop flow) or the root.
this._createFolderBasePath = basePath !== null ? basePath : (this.selectedPath || '');
// 立即保存拖拽状态,防止后续事件(如blur)清空状态 // 立即保存拖拽状态,防止后续事件(如blur)清空状态
this._pendingDragState = { this._pendingDragState = this.draggedFilePaths && this.draggedFilePaths.length > 0 ? {
filePaths: this.draggedFilePaths ? [...this.draggedFilePaths] : null, filePaths: [...this.draggedFilePaths],
rootPath: this.draggedRootPath, rootPath: this.draggedRootPath,
fromBulk: this.draggedFromBulk fromBulk: this.draggedFromBulk
}; } : null;
console.log('[SidebarManager] saved pending drag state:', this._pendingDragState); console.log('[SidebarManager] saved pending drag state:', this._pendingDragState);
this.hideCreateFolderZone(); this.hideCreateFolderZone();
@@ -907,8 +923,9 @@ export class SidebarManager {
return; return;
} }
// Build target path - use selected path as parent, or root if none selected // Build target path - use the base path captured when the input was
const parentPath = this.selectedPath || ''; // opened (context-menu folder or current selection), or root
const parentPath = this._createFolderBasePath || '';
const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName; const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName;
console.log('[SidebarManager] targetRelativePath:', targetRelativePath); console.log('[SidebarManager] targetRelativePath:', targetRelativePath);
@@ -916,15 +933,15 @@ export class SidebarManager {
const pendingState = this._pendingDragState; const pendingState = this._pendingDragState;
console.log('[SidebarManager] using pending drag state:', pendingState); console.log('[SidebarManager] using pending drag state:', pendingState);
if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) {
console.log('[SidebarManager] no pending drag state found, cannot proceed');
showToast('sidebar.dragDrop.noDragState', {}, 'error');
this.hideCreateFolderInput(); this.hideCreateFolderInput();
if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) {
// Standalone creation (header button / context menu): create an
// empty folder on disk instead of moving dragged models into it.
await this._createFolder(targetRelativePath, parentPath);
return; return;
} }
this.hideCreateFolderInput();
// Perform the move with saved state // Perform the move with saved state
console.log('[SidebarManager] calling performDragMove with pending state'); console.log('[SidebarManager] calling performDragMove with pending state');
const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk); const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk);
@@ -947,6 +964,65 @@ export class SidebarManager {
this.clearAllDropHighlights(); this.clearAllDropHighlights();
} }
async _createFolder(targetRelativePath, parentPath) {
if (!this._supportsFolderManagement() || typeof this.apiClient.createFolder !== 'function') {
showToast('sidebar.createFolderResult.unsupported', {}, 'error');
return false;
}
try {
const rootsData = await this.apiClient.fetchModelRoots();
const roots = rootsData?.roots || [];
const root = this._resolveDefaultRoot(roots);
if (!root) {
showToast('sidebar.createFolderResult.noRoot', {}, 'error');
return false;
}
const absolutePath = this.combineRootAndRelativePath(root, targetRelativePath);
const result = await this.apiClient.createFolder(absolutePath);
// The new folder has no models yet — enable empty-folder display
// so it shows up in the tree immediately.
if (!this.showEmptyFolders) {
this.showEmptyFolders = true;
setStorageItem(`${this.pageType}_showEmptyFolders`, true);
this.updateViewOptionsMenu();
}
// Expand the parent folder to reveal the new node
if (parentPath) {
this.expandedNodes.add(parentPath);
this.saveExpandedState();
}
await this.refresh();
showToast('sidebar.createFolderResult.success', { name: result.folder || targetRelativePath }, 'success');
return true;
} catch (error) {
console.error('[SidebarManager] Error creating folder:', error);
showToast('sidebar.createFolderResult.failed', { message: error.message || 'Unknown error' }, 'error');
return false;
}
}
_resolveDefaultRoot(roots) {
if (!roots || roots.length === 0) {
return '';
}
const singularName = this.apiClient?.apiConfig?.config?.singularName;
const defaultRoot = singularName
? state.global?.settings?.[`default_${singularName}_root`]
: '';
if (defaultRoot && roots.includes(defaultRoot)) {
return defaultRoot;
}
return roots[0];
}
handleCreateFolderCancel() { handleCreateFolderCancel() {
this.hideCreateFolderInput(); this.hideCreateFolderInput();
// 清理待处理的拖拽状态 // 清理待处理的拖拽状态
@@ -979,18 +1055,37 @@ export class SidebarManager {
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick); sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
} }
// View options menu button
const viewOptionsBtn = document.getElementById('sidebarViewOptions');
if (viewOptionsBtn) {
viewOptionsBtn.addEventListener('click', this.handleViewOptionsButton);
}
// View options menu items
const viewOptionsMenu = document.getElementById('sidebarViewOptionsMenu');
if (viewOptionsMenu) {
viewOptionsMenu.addEventListener('click', (e) => {
const item = e.target.closest('.context-menu-item');
if (!item || item.classList.contains('disabled')) return;
const action = item.dataset.action;
if (action) {
this.handleViewOptionsAction(action);
}
});
}
// Create folder button
const createFolderBtn = document.getElementById('sidebarCreateFolder');
if (createFolderBtn) {
createFolderBtn.addEventListener('click', this.handleCreateFolderButton);
}
// Collapse all button // Collapse all button
const collapseAllBtn = document.getElementById('sidebarCollapseAll'); const collapseAllBtn = document.getElementById('sidebarCollapseAll');
if (collapseAllBtn) { if (collapseAllBtn) {
collapseAllBtn.addEventListener('click', this.handleCollapseAll); collapseAllBtn.addEventListener('click', this.handleCollapseAll);
} }
// Recursive toggle button
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
if (recursiveToggleBtn) {
recursiveToggleBtn.addEventListener('click', this.handleRecursiveToggle);
}
// Tree click handler // Tree click handler
const folderTree = document.getElementById('sidebarFolderTree'); const folderTree = document.getElementById('sidebarFolderTree');
if (folderTree) { if (folderTree) {
@@ -1025,12 +1120,6 @@ export class SidebarManager {
// Add dedicated resize listener for container margin updates // Add dedicated resize listener for container margin updates
window.addEventListener('resize', this.updateContainerMargin); window.addEventListener('resize', this.updateContainerMargin);
// Display mode toggle button
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
if (displayModeToggleBtn) {
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
}
// Sidebar folder context menu click handler // Sidebar folder context menu click handler
const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu'); const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu');
if (sidebarFolderMenu) { if (sidebarFolderMenu) {
@@ -1071,7 +1160,7 @@ export class SidebarManager {
} }
handleCollapseAll(event) { handleCollapseAll(event) {
event.stopPropagation(); event?.stopPropagation();
this.expandedNodes.clear(); this.expandedNodes.clear();
this.renderFolderDisplay(); this.renderFolderDisplay();
this.saveExpandedState(); this.saveExpandedState();
@@ -1173,12 +1262,35 @@ export class SidebarManager {
async loadFolderTree() { async loadFolderTree() {
try { try {
const includeEmpty = this.showEmptyFolders && this._supportsFolderManagement();
if (this.displayMode === 'tree') { if (this.displayMode === 'tree') {
if (includeEmpty) {
// Fetch the models-only folder list alongside so empty
// directories can be dimmed in the tree.
const [treeResponse, foldersResponse] = await Promise.all([
this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true }),
this.apiClient.fetchModelFolders(),
]);
this.treeData = treeResponse.tree || {};
this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []);
} else {
const response = await this.apiClient.fetchUnifiedFolderTree(); const response = await this.apiClient.fetchUnifiedFolderTree();
this.treeData = response.tree || {}; this.treeData = response.tree || {};
this.nonEmptyFolders = null;
}
} else {
if (includeEmpty) {
const [allFoldersResponse, foldersResponse] = await Promise.all([
this.apiClient.fetchModelFolders({ includeEmpty: true }),
this.apiClient.fetchModelFolders(),
]);
this.foldersList = allFoldersResponse.folders || [];
this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []);
} else { } else {
const response = await this.apiClient.fetchModelFolders(); const response = await this.apiClient.fetchModelFolders();
this.foldersList = response.folders || []; this.foldersList = response.folders || [];
this.nonEmptyFolders = null;
}
} }
this.folderTreeLoaded = true; this.folderTreeLoaded = true;
this.renderFolderDisplay(); this.renderFolderDisplay();
@@ -1189,6 +1301,27 @@ export class SidebarManager {
} }
} }
_supportsFolderManagement() {
return Boolean(this.apiClient?.apiConfig?.config?.supportsFolderManagement);
}
// The models-only folder list only contains directories that directly
// hold model files. Expand it with every ancestor prefix so a folder
// whose subtree contains models (e.g. "a" with models in "a/b") is not
// dimmed as empty — the "empty" style means "no models anywhere below".
_buildNonEmptyFolderSet(folders) {
const set = new Set();
for (const folder of folders) {
set.add(folder);
if (!folder) continue;
const parts = folder.split('/');
for (let i = 1; i < parts.length; i++) {
set.add(parts.slice(0, i).join('/'));
}
}
return set;
}
folderExistsInTree(path) { folderExistsInTree(path) {
if (!path) return true; if (!path) return true;
@@ -1236,6 +1369,7 @@ export class SidebarManager {
const hasChildren = Object.keys(children).length > 0; const hasChildren = Object.keys(children).length > 0;
const isExpanded = this.expandedNodes.has(currentPath); const isExpanded = this.expandedNodes.has(currentPath);
const isSelected = this.selectedPath === currentPath; const isSelected = this.selectedPath === currentPath;
const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(currentPath) : false;
const escapedPath = escapeAttribute(currentPath); const escapedPath = escapeAttribute(currentPath);
const escapedFolderName = escapeHtml(folderName); const escapedFolderName = escapeHtml(folderName);
@@ -1243,12 +1377,12 @@ export class SidebarManager {
return ` return `
<div class="sidebar-tree-node" data-path="${escapedPath}"> <div class="sidebar-tree-node" data-path="${escapedPath}">
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${escapedPath}"> <div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''} ${isEmpty ? 'empty' : ''}" data-path="${escapedPath}">
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}" <div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}"> style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
<i class="fas fa-chevron-right"></i> <i class="fas fa-chevron-right"></i>
</div> </div>
<i class="fas fa-folder sidebar-tree-folder-icon"></i> <i class="fas fa-folder${isEmpty ? '-open' : ''} sidebar-tree-folder-icon"></i>
<div class="sidebar-tree-folder-name" title="${escapedTitle}">${escapedFolderName}</div> <div class="sidebar-tree-folder-name" title="${escapedTitle}">${escapedFolderName}</div>
</div> </div>
${hasChildren ? ` ${hasChildren ? `
@@ -1265,13 +1399,19 @@ export class SidebarManager {
const folderTree = document.getElementById('sidebarFolderTree'); const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) return; if (!folderTree) return;
// Pages with folder management (model libraries) also offer the
// header create button; recipes can only create folders via drag.
const hintKey = this._supportsFolderManagement()
? 'sidebar.empty.createHint'
: 'sidebar.empty.dragHint';
folderTree.innerHTML = ` folderTree.innerHTML = `
<div class="sidebar-tree-placeholder"> <div class="sidebar-tree-placeholder">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open"></i>
<div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div> <div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div>
<div class="sidebar-empty-hint"> <div class="sidebar-empty-hint">
<i class="fas fa-hand-pointer"></i> <i class="fas fa-hand-pointer"></i>
${translate('sidebar.empty.dragHint', {}, 'Drag items here to create folders')} ${translate(hintKey, {}, 'Drag items here to create folders')}
</div> </div>
</div> </div>
`; `;
@@ -1289,14 +1429,15 @@ export class SidebarManager {
const foldersHtml = this.foldersList.map(folder => { const foldersHtml = this.foldersList.map(folder => {
const displayName = folder === '' ? '/' : folder; const displayName = folder === '' ? '/' : folder;
const isSelected = this.selectedPath === folder; const isSelected = this.selectedPath === folder;
const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(folder) : false;
const escapedPath = escapeAttribute(folder); const escapedPath = escapeAttribute(folder);
const escapedDisplayName = escapeHtml(displayName); const escapedDisplayName = escapeHtml(displayName);
const escapedTitle = escapeAttribute(displayName); const escapedTitle = escapeAttribute(displayName);
return ` return `
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}"> <div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
<div class="sidebar-node-content" data-path="${escapedPath}"> <div class="sidebar-node-content ${isEmpty ? 'empty' : ''}" data-path="${escapedPath}">
<i class="fas fa-folder sidebar-folder-icon"></i> <i class="fas fa-folder${isEmpty ? '-open' : ''} sidebar-folder-icon"></i>
<div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div> <div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div>
</div> </div>
</div> </div>
@@ -1355,10 +1496,18 @@ export class SidebarManager {
_showFolderContextMenu(x, y, path) { _showFolderContextMenu(x, y, path) {
this._closeFolderContextMenu(); this._closeFolderContextMenu();
this._closeViewOptionsMenu();
const menu = document.getElementById('sidebarFolderContextMenu'); const menu = document.getElementById('sidebarFolderContextMenu');
if (!menu) return; if (!menu) return;
// Folder creation is only available on pages backed by model library
// roots (not recipes, which have no on-disk folder management).
const createItem = menu.querySelector('[data-action="create-subfolder"]');
if (createItem) {
createItem.style.display = this._supportsFolderManagement() ? '' : 'none';
}
menu.style.left = `${x}px`; menu.style.left = `${x}px`;
menu.style.top = `${y}px`; menu.style.top = `${y}px`;
menu.style.display = 'block'; menu.style.display = 'block';
@@ -1404,6 +1553,9 @@ export class SidebarManager {
async _performFolderAction(action, path) { async _performFolderAction(action, path) {
switch (action) { switch (action) {
case 'create-subfolder':
this.showCreateFolderInput(path);
break;
case 'check-folder-updates': case 'check-folder-updates':
try { try {
await performFolderUpdateCheck(path); await performFolderUpdateCheck(path);
@@ -1488,18 +1640,17 @@ export class SidebarManager {
} }
handleDisplayModeToggle(event) { handleDisplayModeToggle(event) {
event.stopPropagation(); event?.stopPropagation();
this.displayMode = this.displayMode === 'tree' ? 'list' : 'tree'; this.displayMode = this.displayMode === 'tree' ? 'list' : 'tree';
this.updateDisplayModeButton(); this.updateViewOptionsMenu();
this.updateCollapseAllButton(); this.updateCollapseAllButton();
this.updateRecursiveToggleButton();
this.updateSearchRecursiveOption(); this.updateSearchRecursiveOption();
this.saveDisplayMode(); this.saveDisplayMode();
this.loadFolderTree(); // Reload with new display mode this.loadFolderTree(); // Reload with new display mode
} }
async handleRecursiveToggle(event) { async handleRecursiveToggle(event) {
event.stopPropagation(); event?.stopPropagation();
if (this.displayMode !== 'tree') { if (this.displayMode !== 'tree') {
return; return;
@@ -1508,7 +1659,7 @@ export class SidebarManager {
this.recursiveSearchEnabled = !this.recursiveSearchEnabled; this.recursiveSearchEnabled = !this.recursiveSearchEnabled;
setStorageItem(`${this.pageType}_recursiveSearch`, this.recursiveSearchEnabled); setStorageItem(`${this.pageType}_recursiveSearch`, this.recursiveSearchEnabled);
this.updateSearchRecursiveOption(); this.updateSearchRecursiveOption();
this.updateRecursiveToggleButton(); this.updateViewOptionsMenu();
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
try { try {
@@ -1519,59 +1670,149 @@ export class SidebarManager {
} }
} }
updateDisplayModeButton() { handleEmptyFoldersToggle(event) {
const displayModeBtn = document.getElementById('sidebarDisplayModeToggle'); event?.stopPropagation();
if (displayModeBtn) {
const icon = displayModeBtn.querySelector('i'); if (!this._supportsFolderManagement()) {
if (this.displayMode === 'tree') { return;
icon.className = 'fas fa-sitemap';
displayModeBtn.title = translate('sidebar.switchToListView');
} else {
icon.className = 'fas fa-list';
displayModeBtn.title = translate('sidebar.switchToTreeView');
} }
this.showEmptyFolders = !this.showEmptyFolders;
setStorageItem(`${this.pageType}_showEmptyFolders`, this.showEmptyFolders);
this.updateViewOptionsMenu();
this.loadFolderTree();
}
handleCreateFolderButton(event) {
event.stopPropagation();
if (!this._supportsFolderManagement()) {
return;
}
this.showCreateFolderInput();
}
handleViewOptionsButton(event) {
event.stopPropagation();
const menu = document.getElementById('sidebarViewOptionsMenu');
if (!menu) return;
if (menu.style.display === 'block') {
this._closeViewOptionsMenu();
return;
}
this._closeFolderContextMenu();
this.updateViewOptionsMenu();
const anchor = event.currentTarget;
const rect = anchor.getBoundingClientRect();
menu.style.display = 'block';
// Right-align the menu under the button so it stays within the sidebar
menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`;
menu.style.top = `${rect.bottom + 4}px`;
this._viewOptionsCloseHandler = (e) => {
if (!menu.contains(e.target)) {
this._closeViewOptionsMenu();
}
};
setTimeout(() => {
document.addEventListener('click', this._viewOptionsCloseHandler);
}, 0);
}
_closeViewOptionsMenu() {
const menu = document.getElementById('sidebarViewOptionsMenu');
if (menu) {
menu.style.display = 'none';
}
if (this._viewOptionsCloseHandler) {
document.removeEventListener('click', this._viewOptionsCloseHandler);
this._viewOptionsCloseHandler = null;
}
}
handleViewOptionsAction(action) {
switch (action) {
case 'view-mode-tree':
if (this.displayMode !== 'tree') {
this.handleDisplayModeToggle();
}
this._closeViewOptionsMenu();
break;
case 'view-mode-list':
if (this.displayMode !== 'list') {
this.handleDisplayModeToggle();
}
this._closeViewOptionsMenu();
break;
case 'toggle-recursive':
// Keep the menu open so view preferences can be combined
this.handleRecursiveToggle();
break;
case 'toggle-empty-folders':
this.handleEmptyFoldersToggle();
break;
default:
console.warn('Unknown view options action:', action);
}
}
updateFolderManagementButtons() {
const supported = this._supportsFolderManagement();
const createFolderBtn = document.getElementById('sidebarCreateFolder');
if (createFolderBtn) {
createFolderBtn.style.display = supported ? '' : 'none';
}
this.updateViewOptionsMenu();
}
updateViewOptionsMenu() {
const menu = document.getElementById('sidebarViewOptionsMenu');
if (!menu) return;
const setCheck = (action, checked) => {
const check = menu.querySelector(`[data-action="${action}"] .check-indicator`);
if (check) {
check.style.display = checked ? 'block' : 'none';
}
};
const setDisabled = (action, disabled) => {
const item = menu.querySelector(`[data-action="${action}"]`);
if (item) {
item.classList.toggle('disabled', disabled);
}
};
const isTreeMode = this.displayMode === 'tree';
setCheck('view-mode-tree', isTreeMode);
setCheck('view-mode-list', !isTreeMode);
setCheck('toggle-recursive', isTreeMode && this.recursiveSearchEnabled);
setDisabled('toggle-recursive', !isTreeMode);
setCheck('toggle-empty-folders', this.showEmptyFolders && this._supportsFolderManagement());
// Empty-folder display requires a model library backend
const emptyFoldersItem = menu.querySelector('[data-action="toggle-empty-folders"]');
if (emptyFoldersItem) {
emptyFoldersItem.style.display = this._supportsFolderManagement() ? '' : 'none';
} }
} }
updateCollapseAllButton() { updateCollapseAllButton() {
const collapseAllBtn = document.getElementById('sidebarCollapseAll'); const collapseAllBtn = document.getElementById('sidebarCollapseAll');
if (collapseAllBtn) { if (!collapseAllBtn) return;
if (this.displayMode === 'list') {
collapseAllBtn.disabled = true;
collapseAllBtn.classList.add('disabled');
collapseAllBtn.title = translate('sidebar.collapseAllDisabled');
} else {
collapseAllBtn.disabled = false;
collapseAllBtn.classList.remove('disabled');
collapseAllBtn.title = translate('sidebar.collapseAll');
}
}
}
updateRecursiveToggleButton() {
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
if (!recursiveToggleBtn) return;
const icon = recursiveToggleBtn.querySelector('i');
const isTreeMode = this.displayMode === 'tree'; const isTreeMode = this.displayMode === 'tree';
const isActive = isTreeMode && this.recursiveSearchEnabled; collapseAllBtn.disabled = !isTreeMode;
collapseAllBtn.classList.toggle('disabled', !isTreeMode);
recursiveToggleBtn.classList.toggle('active', isActive); collapseAllBtn.title = isTreeMode
recursiveToggleBtn.classList.toggle('disabled', !isTreeMode); ? translate('sidebar.collapseAll')
recursiveToggleBtn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); : translate('sidebar.collapseAllDisabled', {}, 'Not available in list view');
recursiveToggleBtn.setAttribute('aria-disabled', isTreeMode ? 'false' : 'true');
if (icon) {
icon.className = 'fas fa-code-branch';
}
if (!isTreeMode) {
recursiveToggleBtn.title = translate('sidebar.recursiveUnavailable');
} else if (this.recursiveSearchEnabled) {
recursiveToggleBtn.title = translate('sidebar.recursiveOn');
} else {
recursiveToggleBtn.title = translate('sidebar.recursiveOff');
}
} }
updateSearchRecursiveOption() { updateSearchRecursiveOption() {
@@ -1800,11 +2041,11 @@ export class SidebarManager {
this.expandedNodes = new Set(expandedPaths); this.expandedNodes = new Set(expandedPaths);
this.displayMode = displayMode; this.displayMode = displayMode;
this.recursiveSearchEnabled = recursiveSearchEnabled; this.recursiveSearchEnabled = recursiveSearchEnabled;
this.showEmptyFolders = getStorageItem(`${this.pageType}_showEmptyFolders`, false);
this.updateDisplayModeButton();
this.updateCollapseAllButton();
this.updateSearchRecursiveOption(); this.updateSearchRecursiveOption();
this.updateRecursiveToggleButton(); this.updateFolderManagementButtons();
this.updateCollapseAllButton();
} }
/** /**
+24
View File
@@ -206,11 +206,35 @@
<!-- Sidebar Folder Context Menu --> <!-- Sidebar Folder Context Menu -->
<div id="sidebarFolderContextMenu" class="context-menu"> <div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="create-subfolder">
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
</div>
<div class="context-menu-item" data-action="check-folder-updates"> <div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span> <i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
</div> </div>
</div> </div>
<!-- Sidebar View Options Menu -->
<div id="sidebarViewOptionsMenu" class="context-menu">
<div class="context-menu-item" data-action="view-mode-tree">
<i class="fas fa-sitemap"></i> <span>{{ t('sidebar.treeView') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="view-mode-list">
<i class="fas fa-list"></i> <span>{{ t('sidebar.listView') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="toggle-recursive">
<i class="fas fa-code-branch"></i> <span>{{ t('sidebar.recursiveOn') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="toggle-empty-folders">
<i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector"> <div id="nsfwLevelSelector" class="nsfw-level-selector">
<div class="nsfw-level-header"> <div class="nsfw-level-header">
<h3>{{ t('modals.contentRating.title') }}</h3> <h3>{{ t('modals.contentRating.title') }}</h3>
+5 -5
View File
@@ -3,15 +3,15 @@
<div class="sidebar-header" id="sidebarHeader"> <div class="sidebar-header" id="sidebarHeader">
<h3><i class="fas fa-home"></i> <span id="sidebarTitle">{{ t('sidebar.modelRoot') }}</span></h3> <h3><i class="fas fa-home"></i> <span id="sidebarTitle">{{ t('sidebar.modelRoot') }}</span></h3>
<div class="sidebar-header-actions"> <div class="sidebar-header-actions">
<button class="sidebar-action-btn" id="sidebarDisplayModeToggle" title="{{ t('sidebar.switchToListView') }}"> <button class="sidebar-action-btn" id="sidebarCreateFolder" title="{{ t('sidebar.createFolder') }}">
<i class="fas fa-sitemap"></i> <i class="fas fa-folder-plus"></i>
</button>
<button class="sidebar-action-btn active" id="sidebarRecursiveToggle" title="{{ t('sidebar.recursiveOn') }}" aria-pressed="true">
<i class="fas fa-code-branch"></i>
</button> </button>
<button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}"> <button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}">
<i class="fas fa-compress-alt"></i> <i class="fas fa-compress-alt"></i>
</button> </button>
<button class="sidebar-action-btn" id="sidebarViewOptions" title="{{ t('sidebar.viewOptions') }}" aria-haspopup="true">
<i class="fas fa-ellipsis-v"></i>
</button>
<button class="sidebar-action-btn" id="sidebarHideToggle" title="{{ t('sidebar.hideOnThisPage') }}"> <button class="sidebar-action-btn" id="sidebarHideToggle" title="{{ t('sidebar.hideOnThisPage') }}">
<i class="fas fa-chevron-left"></i> <i class="fas fa-chevron-left"></i>
</button> </button>
@@ -0,0 +1,360 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const {
SIDEBAR_MANAGER_MODULE,
STORAGE_HELPERS_MODULE,
MODEL_API_FACTORY_MODULE,
I18N_MODULE,
BULK_MANAGER_MODULE,
UI_HELPERS_MODULE,
UPDATE_CHECK_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() }));
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
const { state } = await import(STATE_MODULE);
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
function createApiClient(overrides = {}) {
return {
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
supportsMove: true,
supportsFolderManagement: true,
},
},
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ tree: { full: {}, empty: {} } }),
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
...overrides,
};
}
function createManager(apiClient, { displayMode = 'tree' } = {}) {
const manager = new SidebarManager();
manager.pageType = 'loras';
manager.displayMode = displayMode;
manager.apiClient = apiClient;
manager.pageControls = { pageState: { searchOptions: {} } };
manager.renderFolderDisplay = vi.fn();
manager.renderEmptyState = vi.fn();
return manager;
}
describe('SidebarManager empty folders toggle', () => {
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '';
});
it('requests the models-only tree by default', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
expect(manager.nonEmptyFolders).toBeNull();
expect(manager.treeData).toEqual({ full: {}, empty: {} });
});
it('includes empty folders and tracks the models-only set when enabled', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.showEmptyFolders = true;
await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
expect(apiClient.fetchModelFolders).toHaveBeenCalledWith();
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
});
it('passes includeEmpty to the folder list in list display mode', async () => {
const apiClient = createApiClient();
apiClient.fetchModelFolders
.mockResolvedValueOnce({ folders: ['', 'full', 'empty'] })
.mockResolvedValueOnce({ folders: ['', 'full'] });
const manager = createManager(apiClient, { displayMode: 'list' });
manager.showEmptyFolders = true;
await manager.loadFolderTree();
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true });
expect(manager.foldersList).toEqual(['', 'full', 'empty']);
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
});
it('ignores the preference when the page does not support folder management', async () => {
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager.showEmptyFolders = true;
await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
expect(manager.nonEmptyFolders).toBeNull();
});
it('persists the toggle and reloads the tree', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.loadFolderTree = vi.fn();
manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() });
expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
expect(manager.loadFolderTree).toHaveBeenCalledTimes(1);
});
it('dims folders that contain no models', () => {
const manager = createManager(createApiClient());
manager.treeData = { full: {}, empty: {} };
manager.nonEmptyFolders = new Set(['', 'full']);
const html = manager.renderTreeNode(manager.treeData, '');
expect(html).toContain('sidebar-tree-node-content empty');
expect(html.match(/empty/g).length).toBeGreaterThan(0);
expect(html).not.toContain('sidebar-tree-node-content empty" data-path="full"');
});
it('does not dim folders whose subtree contains models', () => {
const manager = createManager(createApiClient());
// Models live in "characters/anime" only; "characters" itself holds no
// direct models but must not be dimmed.
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']);
manager.treeData = { characters: { anime: {} }, empty: {} };
const html = manager.renderTreeNode(manager.treeData, '');
const charactersNode = html.match(/<div class="sidebar-tree-node-content[^"]*" data-path="characters">/);
const animeNode = html.match(/<div class="sidebar-tree-node-content[^"]*" data-path="characters\/anime">/);
const emptyNode = html.match(/<div class="sidebar-tree-node-content[^"]*" data-path="empty">/);
expect(charactersNode[0]).not.toContain('empty');
expect(animeNode[0]).not.toContain('empty');
expect(emptyNode[0]).toContain('empty');
});
});
describe('SidebarManager view options menu', () => {
const MENU_HTML = `
<div id="sidebarViewOptionsMenu" class="context-menu">
<div class="context-menu-item" data-action="view-mode-tree"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="view-mode-list"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="toggle-recursive"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="toggle-empty-folders"><i class="check-indicator" style="display:none"></i></div>
</div>`;
function getCheck(action) {
return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`);
}
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = MENU_HTML;
});
it('reflects the current view state with check indicators', () => {
const manager = createManager(createApiClient());
manager.displayMode = 'tree';
manager.recursiveSearchEnabled = true;
manager.showEmptyFolders = false;
manager.updateViewOptionsMenu();
expect(getCheck('view-mode-tree').style.display).toBe('block');
expect(getCheck('view-mode-list').style.display).toBe('none');
expect(getCheck('toggle-recursive').style.display).toBe('block');
expect(getCheck('toggle-empty-folders').style.display).toBe('none');
});
it('disables the recursive toggle in list mode', () => {
const manager = createManager(createApiClient());
manager.displayMode = 'list';
manager.updateViewOptionsMenu();
expect(document.querySelector('[data-action="toggle-recursive"]').classList.contains('disabled')).toBe(true);
});
it('hides the empty-folders item when folder management is unsupported', () => {
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager.updateViewOptionsMenu();
expect(document.querySelector('[data-action="toggle-empty-folders"]').style.display).toBe('none');
});
it('switches display mode from the menu and closes it', () => {
const manager = createManager(createApiClient());
manager.loadFolderTree = vi.fn();
const menu = document.getElementById('sidebarViewOptionsMenu');
menu.style.display = 'block';
manager.handleViewOptionsAction('view-mode-list');
expect(manager.displayMode).toBe('list');
expect(manager.loadFolderTree).toHaveBeenCalledTimes(1);
expect(menu.style.display).toBe('none');
});
it('toggles empty folders from the menu and keeps it open', () => {
const manager = createManager(createApiClient());
manager.loadFolderTree = vi.fn();
const menu = document.getElementById('sidebarViewOptionsMenu');
menu.style.display = 'block';
manager.handleViewOptionsAction('toggle-empty-folders');
expect(manager.showEmptyFolders).toBe(true);
expect(getCheck('toggle-empty-folders').style.display).toBe('block');
expect(menu.style.display).toBe('block');
});
it('collapses all folders from the header button', () => {
document.body.insertAdjacentHTML('beforeend',
'<button id="sidebarCollapseAll"><i class="fas fa-compress-alt"></i></button>');
const manager = createManager(createApiClient());
manager.expandedNodes = new Set(['a', 'a/b']);
manager.handleCollapseAll();
expect(manager.expandedNodes.size).toBe(0);
expect(manager.renderFolderDisplay).toHaveBeenCalledTimes(1);
});
it('disables the collapse-all header button in list mode', () => {
document.body.insertAdjacentHTML('beforeend',
'<button id="sidebarCollapseAll"><i class="fas fa-compress-alt"></i></button>');
const manager = createManager(createApiClient());
manager.displayMode = 'list';
manager.updateCollapseAllButton();
const button = document.getElementById('sidebarCollapseAll');
expect(button.disabled).toBe(true);
expect(button.classList.contains('disabled')).toBe(true);
manager.displayMode = 'tree';
manager.updateCollapseAllButton();
expect(button.disabled).toBe(false);
expect(button.classList.contains('disabled')).toBe(false);
});
it('toggles the menu open and closed from the header button', () => {
const manager = createManager(createApiClient());
const button = document.createElement('button');
document.body.appendChild(button);
const menu = document.getElementById('sidebarViewOptionsMenu');
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
expect(menu.style.display).toBe('block');
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
expect(menu.style.display).toBe('none');
});
});
describe('SidebarManager folder creation', () => {
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '';
state.global.settings = {};
});
it('resolves the configured default root when it is a known root', () => {
const manager = createManager(createApiClient());
state.global.settings = { default_lora_root: '/models/loras-main' };
const root = manager._resolveDefaultRoot(['/models/loras', '/models/loras-main']);
expect(root).toBe('/models/loras-main');
});
it('falls back to the first root when no default is configured', () => {
const manager = createManager(createApiClient());
expect(manager._resolveDefaultRoot(['/models/loras'])).toBe('/models/loras');
expect(manager._resolveDefaultRoot([])).toBe('');
});
it('creates the folder under the selected path and reveals it', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.selectedPath = 'characters';
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._createFolder('characters/anime', 'characters');
expect(success).toBe(true);
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime');
// Empty-folder display is enabled so the new folder shows up immediately
expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
expect(manager.expandedNodes.has('characters')).toBe(true);
expect(manager.refresh).toHaveBeenCalledTimes(1);
});
it('fails gracefully when no model root is configured', async () => {
const apiClient = createApiClient();
apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });
const manager = createManager(apiClient);
manager.refresh = vi.fn();
const success = await manager._createFolder('new-folder', '');
expect(success).toBe(false);
expect(apiClient.createFolder).not.toHaveBeenCalled();
expect(manager.refresh).not.toHaveBeenCalled();
});
it('opens the create-folder input for a context-menu folder', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div class="sidebar-tree-container"></div>';
manager._performFolderAction('create-subfolder', 'characters');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
expect(input).not.toBeNull();
expect(manager._createFolderBasePath).toBe('characters');
});
it('submits a standalone folder creation when no drag is pending', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
document.body.innerHTML = '<div class="sidebar-tree-container"></div>';
manager.showCreateFolderInput('characters');
document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime';
await manager.handleCreateFolderSubmit();
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime');
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
import json
import logging
import pytest
from py.routes.handlers.model_handlers import ModelMoveHandler
class FakeMoveService:
def __init__(self, result):
self._result = result
self.received_path = None
async def create_folder(self, folder_path):
self.received_path = folder_path
return self._result
class FakeRequest:
def __init__(self, payload):
self._payload = payload
async def json(self):
return self._payload
def _make_handler(result):
service = FakeMoveService(result)
handler = ModelMoveHandler(
move_service=service, logger=logging.getLogger(__name__)
)
return handler, service
@pytest.mark.asyncio
async def test_create_folder_success():
handler, service = _make_handler(
{"success": True, "folder": "characters/anime", "created": True}
)
response = await handler.create_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["folder"] == "characters/anime"
assert service.received_path == "/library/characters/anime"
@pytest.mark.asyncio
async def test_create_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.create_folder(FakeRequest({}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_create_folder_service_failure_maps_to_400():
handler, _service = _make_handler(
{
"success": False,
"error": "Folder path '/etc/evil' is outside configured library directories",
}
)
response = await handler.create_folder(FakeRequest({"folder_path": "/etc/evil"}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "outside configured library" in payload["error"]
@pytest.mark.asyncio
async def test_create_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.create_folder(BadJsonRequest())
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
+89
View File
@@ -0,0 +1,89 @@
"""Tests for ModelMoveService folder creation."""
from __future__ import annotations
from pathlib import Path
from typing import List
import pytest
from py.services.model_file_service import ModelMoveService
class FakeScanner:
def __init__(self, roots: List[Path]) -> None:
self._roots = [str(root) for root in roots]
self.known_folders: List[str] = []
def get_model_roots(self) -> List[str]:
return list(self._roots)
async def add_known_folder(self, folder: str) -> None:
self.known_folders.append(folder)
@pytest.mark.asyncio
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
target = tmp_path / "characters" / "anime"
result = await service.create_folder(str(target))
assert result["success"] is True
assert result["created"] is True
assert result["folder"] == "characters/anime"
assert target.is_dir()
assert scanner.known_folders == ["characters/anime"]
@pytest.mark.asyncio
async def test_create_folder_existing_directory_reports_not_created(tmp_path: Path):
(tmp_path / "existing").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.create_folder(str(tmp_path / "existing"))
assert result["success"] is True
assert result["created"] is False
assert result["folder"] == "existing"
@pytest.mark.asyncio
async def test_create_folder_rejects_paths_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
outside = tmp_path / "outside"
result = await service.create_folder(str(outside))
assert result["success"] is False
assert "error" in result
assert not outside.exists()
assert scanner.known_folders == []
@pytest.mark.asyncio
async def test_create_folder_rejects_traversal_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
result = await service.create_folder(str(root / ".." / "escape"))
assert result["success"] is False
assert not (tmp_path / "escape").exists()
assert scanner.known_folders == []
@pytest.mark.asyncio
async def test_create_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
result = await service.create_folder("")
assert result["success"] is False
+47
View File
@@ -1500,6 +1500,53 @@ async def test_get_all_folders_backfills_when_never_recorded(tmp_path: Path):
assert "empty" in all_folders assert "empty" in all_folders
@pytest.mark.asyncio
async def test_add_known_folder_records_folder_and_parents(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
await scanner.add_known_folder("new/nested")
assert "new" in cache.all_folders
assert "new/nested" in cache.all_folders
# Models-only folder list is unaffected by empty directory creation
assert "new" not in cache.folders
# Idempotent: recording the same folder again keeps the list stable
await scanner.add_known_folder("new/nested")
assert cache.all_folders.count("new") == 1
@pytest.mark.asyncio
async def test_add_known_folder_noop_without_recorded_folders(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
cache.all_folders = None
# Legacy snapshot without recorded folders: the scheduled backfill walk
# discovers the directory from disk instead.
await scanner.add_known_folder("new")
assert cache.all_folders is None
@pytest.mark.asyncio
async def test_add_known_folder_ignores_empty_input(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
before = list(cache.all_folders)
await scanner.add_known_folder("")
await scanner.add_known_folder("/")
assert cache.all_folders == before
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_all_folders_updated_after_move(tmp_path: Path): async def test_get_all_folders_updated_after_move(tmp_path: Path):
first, _, _ = _create_files(tmp_path) first, _, _ = _create_files(tmp_path)