mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
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:
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
||||
"showSidebar": "Seitenleiste anzeigen",
|
||||
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "Unterordner einbeziehen",
|
||||
"recursiveOff": "Nur aktueller Ordner",
|
||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"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": {
|
||||
"label": "Auf Updates in diesem Ordner prüfen",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Root",
|
||||
"collapseAll": "Collapse All Folders",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"hideOnThisPage": "Hide sidebar on this page",
|
||||
"showSidebar": "Show sidebar",
|
||||
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
||||
"switchToListView": "Switch to List View",
|
||||
"switchToTreeView": "Switch to Tree View",
|
||||
"viewOptions": "View options",
|
||||
"treeView": "Tree view",
|
||||
"listView": "List view",
|
||||
"recursiveOn": "Include subfolders",
|
||||
"recursiveOff": "Current folder only",
|
||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"createFolder": "New folder",
|
||||
"newSubfolder": "New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||
"moveUnsupported": "Move is not supported for this item.",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"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": {
|
||||
"label": "Check for updates in this folder",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "Ocultar barra lateral en esta página",
|
||||
"showSidebar": "Mostrar barra lateral",
|
||||
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
|
||||
"switchToListView": "Cambiar a vista de lista",
|
||||
"switchToTreeView": "Cambiar a vista de árbol",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "Incluir subcarpetas",
|
||||
"recursiveOff": "Solo carpeta actual",
|
||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||
"moveUnsupported": "El movimiento no es compatible con este elemento.",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"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": {
|
||||
"label": "Buscar actualizaciones en esta carpeta",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "Masquer la barre latérale sur cette page",
|
||||
"showSidebar": "Afficher la barre latérale",
|
||||
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
|
||||
"switchToListView": "Passer en vue liste",
|
||||
"switchToTreeView": "Passer en vue arborescence",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "Inclure les sous-dossiers",
|
||||
"recursiveOff": "Dossier actuel uniquement",
|
||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"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": {
|
||||
"label": "Vérifier les mises à jour dans ce dossier",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||
"showSidebar": "הצג סרגל צד",
|
||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
"recursiveOff": "רק התיקייה הנוכחית",
|
||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "לא נמצאו תיקיות",
|
||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "בדוק עדכונים בתיקייה זו",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||
"showSidebar": "サイドバーを表示",
|
||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
"recursiveOff": "現在のフォルダーのみ",
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||
"moveUnsupported": "この項目の移動はサポートされていません。",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "フォルダが見つかりません",
|
||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "このフォルダのアップデートを確認",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||
"showSidebar": "사이드바 표시",
|
||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
"recursiveOff": "현재 폴더만",
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "이 폴더의 업데이트 확인",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||
"showSidebar": "Показать боковую панель",
|
||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
"recursiveOff": "Только текущая папка",
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Папки не найдены",
|
||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Проверить обновления в этой папке",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||
"showSidebar": "显示侧边栏",
|
||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
"recursiveOff": "仅当前文件夹",
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||
"moveUnsupported": "此条目不支持移动。",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到文件夹",
|
||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||
"dragHint": "拖拽项目到此处以创建文件夹",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "检查此文件夹的更新",
|
||||
|
||||
+15
-6
@@ -1246,15 +1246,23 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||
"showSidebar": "顯示側邊欄",
|
||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
"recursiveOff": "僅目前資料夾",
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"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": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||
"moveUnsupported": "此項目不支援移動。",
|
||||
@@ -1267,7 +1275,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到資料夾",
|
||||
"dragHint": "將項目拖到此處以建立資料夾"
|
||||
"dragHint": "將項目拖到此處以建立資料夾",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "檢查此資料夾的更新",
|
||||
|
||||
@@ -2479,6 +2479,26 @@ class ModelMoveHandler:
|
||||
self._move_service = move_service
|
||||
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:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -3429,6 +3449,7 @@ class ModelHandlerSet:
|
||||
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
||||
"move_model": self.move.move_model,
|
||||
"move_models_bulk": self.move.move_models_bulk,
|
||||
"create_folder": self.move.create_folder,
|
||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||
"get_model_notes": self.query.get_model_notes,
|
||||
|
||||
@@ -40,6 +40,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"),
|
||||
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}/create-folder", "create_folder"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||
RouteDefinition(
|
||||
|
||||
@@ -484,6 +484,58 @@ class ModelMoveService:
|
||||
self.scanner = scanner
|
||||
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]:
|
||||
"""Move a single model file
|
||||
|
||||
|
||||
@@ -1477,6 +1477,34 @@ class ModelScanner:
|
||||
|
||||
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:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
|
||||
@@ -228,6 +228,18 @@
|
||||
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-node-content.drop-target .sidebar-folder-icon {
|
||||
color: var(--lora-accent);
|
||||
@@ -666,10 +678,11 @@
|
||||
|
||||
/* Create folder input container */
|
||||
.sidebar-create-folder-input-container {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
/* Sticky footer inside the scroll container: always visible at the
|
||||
bottom of the viewport regardless of tree scroll position */
|
||||
position: sticky;
|
||||
bottom: 8px;
|
||||
margin: 8px 16px 0;
|
||||
padding: 12px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -703,6 +716,7 @@
|
||||
|
||||
.sidebar-create-folder-input {
|
||||
flex: 1;
|
||||
min-width: 0; /* allow the input to shrink below its intrinsic width */
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
|
||||
@@ -22,6 +22,7 @@ export const MODEL_CONFIG = {
|
||||
supportsLetterFilter: true,
|
||||
supportsBulkOperations: true,
|
||||
supportsMove: true,
|
||||
supportsFolderManagement: true,
|
||||
templateName: 'loras.html'
|
||||
},
|
||||
[MODEL_TYPES.CHECKPOINT]: {
|
||||
@@ -31,6 +32,7 @@ export const MODEL_CONFIG = {
|
||||
supportsLetterFilter: false,
|
||||
supportsBulkOperations: true,
|
||||
supportsMove: true,
|
||||
supportsFolderManagement: true,
|
||||
templateName: 'checkpoints.html'
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
@@ -40,6 +42,7 @@ export const MODEL_CONFIG = {
|
||||
supportsLetterFilter: true,
|
||||
supportsBulkOperations: true,
|
||||
supportsMove: true,
|
||||
supportsFolderManagement: true,
|
||||
templateName: 'embeddings.html'
|
||||
},
|
||||
[MODEL_TYPES.OTHER]: {
|
||||
@@ -49,6 +52,7 @@ export const MODEL_CONFIG = {
|
||||
supportsLetterFilter: false,
|
||||
supportsBulkOperations: true,
|
||||
supportsMove: true,
|
||||
supportsFolderManagement: true,
|
||||
templateName: 'other.html'
|
||||
}
|
||||
};
|
||||
@@ -79,6 +83,7 @@ export function getApiEndpoints(modelType) {
|
||||
// Move operations (now common for all model types that support move)
|
||||
moveModel: `/api/lm/${modelType}/move_model`,
|
||||
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
|
||||
createFolder: `/api/lm/${modelType}/create-folder`,
|
||||
|
||||
// CivitAI integration
|
||||
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
|
||||
|
||||
@@ -1295,9 +1295,13 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchModelFolders() {
|
||||
async fetchModelFolders(options = {}) {
|
||||
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) {
|
||||
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 = {}) {
|
||||
try {
|
||||
const { includeEmpty = false } = options;
|
||||
|
||||
@@ -44,6 +44,9 @@ export class SidebarManager {
|
||||
this.initializationPromise = null;
|
||||
this.isCreatingFolder = false;
|
||||
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
||||
this.showEmptyFolders = false;
|
||||
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
|
||||
this._createFolderBasePath = null;
|
||||
|
||||
// Bind methods
|
||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||
@@ -56,6 +59,9 @@ export class SidebarManager {
|
||||
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
||||
this.handleFolderListClick = this.handleFolderListClick.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.handleCardDragEnd = this.handleCardDragEnd.bind(this);
|
||||
this.handleFolderDragEnter = this.handleFolderDragEnter.bind(this);
|
||||
@@ -138,6 +144,9 @@ export class SidebarManager {
|
||||
this.apiClient = null;
|
||||
this.isInitialized = false;
|
||||
this.recursiveSearchEnabled = true;
|
||||
this.showEmptyFolders = false;
|
||||
this.nonEmptyFolders = null;
|
||||
this._createFolderBasePath = null;
|
||||
|
||||
// Reset container margin
|
||||
const container = document.querySelector('.container');
|
||||
@@ -153,16 +162,10 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
removeEventHandlers() {
|
||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
||||
|
||||
if (collapseAllBtn) {
|
||||
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
||||
}
|
||||
if (folderTree) {
|
||||
folderTree.removeEventListener('click', this.handleTreeClick);
|
||||
folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu);
|
||||
@@ -181,11 +184,20 @@ export class SidebarManager {
|
||||
// Remove resize event handler
|
||||
window.removeEventListener('resize', this.updateContainerMargin);
|
||||
|
||||
if (displayModeToggleBtn) {
|
||||
displayModeToggleBtn.removeEventListener('click', this.handleDisplayModeToggle);
|
||||
const viewOptionsBtn = document.getElementById('sidebarViewOptions');
|
||||
if (viewOptionsBtn) {
|
||||
viewOptionsBtn.removeEventListener('click', this.handleViewOptionsButton);
|
||||
}
|
||||
if (recursiveToggleBtn) {
|
||||
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
|
||||
this._closeViewOptionsMenu();
|
||||
|
||||
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');
|
||||
@@ -773,16 +785,20 @@ export class SidebarManager {
|
||||
}
|
||||
}
|
||||
|
||||
showCreateFolderInput() {
|
||||
showCreateFolderInput(basePath = null) {
|
||||
console.log('[SidebarManager] showCreateFolderInput called');
|
||||
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)清空状态
|
||||
this._pendingDragState = {
|
||||
filePaths: this.draggedFilePaths ? [...this.draggedFilePaths] : null,
|
||||
this._pendingDragState = this.draggedFilePaths && this.draggedFilePaths.length > 0 ? {
|
||||
filePaths: [...this.draggedFilePaths],
|
||||
rootPath: this.draggedRootPath,
|
||||
fromBulk: this.draggedFromBulk
|
||||
};
|
||||
} : null;
|
||||
console.log('[SidebarManager] saved pending drag state:', this._pendingDragState);
|
||||
|
||||
this.hideCreateFolderZone();
|
||||
@@ -907,8 +923,9 @@ export class SidebarManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build target path - use selected path as parent, or root if none selected
|
||||
const parentPath = this.selectedPath || '';
|
||||
// Build target path - use the base path captured when the input was
|
||||
// opened (context-menu folder or current selection), or root
|
||||
const parentPath = this._createFolderBasePath || '';
|
||||
const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
||||
console.log('[SidebarManager] targetRelativePath:', targetRelativePath);
|
||||
|
||||
@@ -916,15 +933,15 @@ export class SidebarManager {
|
||||
const pendingState = this._pendingDragState;
|
||||
console.log('[SidebarManager] using pending drag state:', pendingState);
|
||||
|
||||
this.hideCreateFolderInput();
|
||||
|
||||
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();
|
||||
// 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;
|
||||
}
|
||||
|
||||
this.hideCreateFolderInput();
|
||||
|
||||
// Perform the move with saved state
|
||||
console.log('[SidebarManager] calling performDragMove with pending state');
|
||||
const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk);
|
||||
@@ -947,6 +964,65 @@ export class SidebarManager {
|
||||
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() {
|
||||
this.hideCreateFolderInput();
|
||||
// 清理待处理的拖拽状态
|
||||
@@ -979,18 +1055,37 @@ export class SidebarManager {
|
||||
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
|
||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||
if (collapseAllBtn) {
|
||||
collapseAllBtn.addEventListener('click', this.handleCollapseAll);
|
||||
}
|
||||
|
||||
// Recursive toggle button
|
||||
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
||||
if (recursiveToggleBtn) {
|
||||
recursiveToggleBtn.addEventListener('click', this.handleRecursiveToggle);
|
||||
}
|
||||
|
||||
// Tree click handler
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
if (folderTree) {
|
||||
@@ -1025,12 +1120,6 @@ export class SidebarManager {
|
||||
// Add dedicated resize listener for container margin updates
|
||||
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
|
||||
const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu');
|
||||
if (sidebarFolderMenu) {
|
||||
@@ -1071,7 +1160,7 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
handleCollapseAll(event) {
|
||||
event.stopPropagation();
|
||||
event?.stopPropagation();
|
||||
this.expandedNodes.clear();
|
||||
this.renderFolderDisplay();
|
||||
this.saveExpandedState();
|
||||
@@ -1173,12 +1262,35 @@ export class SidebarManager {
|
||||
|
||||
async loadFolderTree() {
|
||||
try {
|
||||
const includeEmpty = this.showEmptyFolders && this._supportsFolderManagement();
|
||||
if (this.displayMode === 'tree') {
|
||||
const response = await this.apiClient.fetchUnifiedFolderTree();
|
||||
this.treeData = response.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();
|
||||
this.treeData = response.tree || {};
|
||||
this.nonEmptyFolders = null;
|
||||
}
|
||||
} else {
|
||||
const response = await this.apiClient.fetchModelFolders();
|
||||
this.foldersList = response.folders || [];
|
||||
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 {
|
||||
const response = await this.apiClient.fetchModelFolders();
|
||||
this.foldersList = response.folders || [];
|
||||
this.nonEmptyFolders = null;
|
||||
}
|
||||
}
|
||||
this.folderTreeLoaded = true;
|
||||
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) {
|
||||
if (!path) return true;
|
||||
|
||||
@@ -1236,6 +1369,7 @@ export class SidebarManager {
|
||||
const hasChildren = Object.keys(children).length > 0;
|
||||
const isExpanded = this.expandedNodes.has(currentPath);
|
||||
const isSelected = this.selectedPath === currentPath;
|
||||
const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(currentPath) : false;
|
||||
|
||||
const escapedPath = escapeAttribute(currentPath);
|
||||
const escapedFolderName = escapeHtml(folderName);
|
||||
@@ -1243,12 +1377,12 @@ export class SidebarManager {
|
||||
|
||||
return `
|
||||
<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' : ''}"
|
||||
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</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>
|
||||
${hasChildren ? `
|
||||
@@ -1265,13 +1399,19 @@ export class SidebarManager {
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
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 = `
|
||||
<div class="sidebar-tree-placeholder">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div>
|
||||
<div class="sidebar-empty-hint">
|
||||
<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>
|
||||
`;
|
||||
@@ -1289,14 +1429,15 @@ export class SidebarManager {
|
||||
const foldersHtml = this.foldersList.map(folder => {
|
||||
const displayName = folder === '' ? '/' : folder;
|
||||
const isSelected = this.selectedPath === folder;
|
||||
const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(folder) : false;
|
||||
const escapedPath = escapeAttribute(folder);
|
||||
const escapedDisplayName = escapeHtml(displayName);
|
||||
const escapedTitle = escapeAttribute(displayName);
|
||||
|
||||
return `
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
||||
<div class="sidebar-node-content" data-path="${escapedPath}">
|
||||
<i class="fas fa-folder sidebar-folder-icon"></i>
|
||||
<div class="sidebar-node-content ${isEmpty ? 'empty' : ''}" data-path="${escapedPath}">
|
||||
<i class="fas fa-folder${isEmpty ? '-open' : ''} sidebar-folder-icon"></i>
|
||||
<div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1355,10 +1496,18 @@ export class SidebarManager {
|
||||
|
||||
_showFolderContextMenu(x, y, path) {
|
||||
this._closeFolderContextMenu();
|
||||
this._closeViewOptionsMenu();
|
||||
|
||||
const menu = document.getElementById('sidebarFolderContextMenu');
|
||||
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.top = `${y}px`;
|
||||
menu.style.display = 'block';
|
||||
@@ -1404,6 +1553,9 @@ export class SidebarManager {
|
||||
|
||||
async _performFolderAction(action, path) {
|
||||
switch (action) {
|
||||
case 'create-subfolder':
|
||||
this.showCreateFolderInput(path);
|
||||
break;
|
||||
case 'check-folder-updates':
|
||||
try {
|
||||
await performFolderUpdateCheck(path);
|
||||
@@ -1488,18 +1640,17 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
handleDisplayModeToggle(event) {
|
||||
event.stopPropagation();
|
||||
event?.stopPropagation();
|
||||
this.displayMode = this.displayMode === 'tree' ? 'list' : 'tree';
|
||||
this.updateDisplayModeButton();
|
||||
this.updateViewOptionsMenu();
|
||||
this.updateCollapseAllButton();
|
||||
this.updateRecursiveToggleButton();
|
||||
this.updateSearchRecursiveOption();
|
||||
this.saveDisplayMode();
|
||||
this.loadFolderTree(); // Reload with new display mode
|
||||
}
|
||||
|
||||
async handleRecursiveToggle(event) {
|
||||
event.stopPropagation();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (this.displayMode !== 'tree') {
|
||||
return;
|
||||
@@ -1508,7 +1659,7 @@ export class SidebarManager {
|
||||
this.recursiveSearchEnabled = !this.recursiveSearchEnabled;
|
||||
setStorageItem(`${this.pageType}_recursiveSearch`, this.recursiveSearchEnabled);
|
||||
this.updateSearchRecursiveOption();
|
||||
this.updateRecursiveToggleButton();
|
||||
this.updateViewOptionsMenu();
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
try {
|
||||
@@ -1519,59 +1670,149 @@ export class SidebarManager {
|
||||
}
|
||||
}
|
||||
|
||||
updateDisplayModeButton() {
|
||||
const displayModeBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||
if (displayModeBtn) {
|
||||
const icon = displayModeBtn.querySelector('i');
|
||||
if (this.displayMode === 'tree') {
|
||||
icon.className = 'fas fa-sitemap';
|
||||
displayModeBtn.title = translate('sidebar.switchToListView');
|
||||
} else {
|
||||
icon.className = 'fas fa-list';
|
||||
displayModeBtn.title = translate('sidebar.switchToTreeView');
|
||||
handleEmptyFoldersToggle(event) {
|
||||
event?.stopPropagation();
|
||||
|
||||
if (!this._supportsFolderManagement()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||
if (collapseAllBtn) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!collapseAllBtn) return;
|
||||
|
||||
updateRecursiveToggleButton() {
|
||||
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
||||
if (!recursiveToggleBtn) return;
|
||||
|
||||
const icon = recursiveToggleBtn.querySelector('i');
|
||||
const isTreeMode = this.displayMode === 'tree';
|
||||
const isActive = isTreeMode && this.recursiveSearchEnabled;
|
||||
|
||||
recursiveToggleBtn.classList.toggle('active', isActive);
|
||||
recursiveToggleBtn.classList.toggle('disabled', !isTreeMode);
|
||||
recursiveToggleBtn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
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');
|
||||
}
|
||||
collapseAllBtn.disabled = !isTreeMode;
|
||||
collapseAllBtn.classList.toggle('disabled', !isTreeMode);
|
||||
collapseAllBtn.title = isTreeMode
|
||||
? translate('sidebar.collapseAll')
|
||||
: translate('sidebar.collapseAllDisabled', {}, 'Not available in list view');
|
||||
}
|
||||
|
||||
updateSearchRecursiveOption() {
|
||||
@@ -1800,11 +2041,11 @@ export class SidebarManager {
|
||||
this.expandedNodes = new Set(expandedPaths);
|
||||
this.displayMode = displayMode;
|
||||
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
||||
this.showEmptyFolders = getStorageItem(`${this.pageType}_showEmptyFolders`, false);
|
||||
|
||||
this.updateDisplayModeButton();
|
||||
this.updateCollapseAllButton();
|
||||
this.updateSearchRecursiveOption();
|
||||
this.updateRecursiveToggleButton();
|
||||
this.updateFolderManagementButtons();
|
||||
this.updateCollapseAllButton();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -206,11 +206,35 @@
|
||||
|
||||
<!-- Sidebar Folder 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">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||
</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 class="nsfw-level-header">
|
||||
<h3>{{ t('modals.contentRating.title') }}</h3>
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
<div class="sidebar-header" id="sidebarHeader">
|
||||
<h3><i class="fas fa-home"></i> <span id="sidebarTitle">{{ t('sidebar.modelRoot') }}</span></h3>
|
||||
<div class="sidebar-header-actions">
|
||||
<button class="sidebar-action-btn" id="sidebarDisplayModeToggle" title="{{ t('sidebar.switchToListView') }}">
|
||||
<i class="fas fa-sitemap"></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 class="sidebar-action-btn" id="sidebarCreateFolder" title="{{ t('sidebar.createFolder') }}">
|
||||
<i class="fas fa-folder-plus"></i>
|
||||
</button>
|
||||
<button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}">
|
||||
<i class="fas fa-compress-alt"></i>
|
||||
</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') }}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1500,6 +1500,53 @@ async def test_get_all_folders_backfills_when_never_recorded(tmp_path: Path):
|
||||
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
|
||||
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
||||
first, _, _ = _create_files(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user