mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -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": {
|
"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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "檢查此資料夾的更新",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user