mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-08 10:32:13 -03:00
fix(sidebar): keep the recipes storage dir out of folder management
The default recipes dir (<first lora root>/recipes) is auto-created by the recipe scanner and recorded by the model-root walks as an empty folder, so it showed up in the folder sidebar — and since recipe files are not model weight files, the folder delete endpoint's model_count check let a right-click delete wipe the whole recipe library via shutil.rmtree. - scan walks (full scan, reconcile, all-folders backfill) no longer descend into the effective recipes dir, and get_all_folders() filters it out of snapshots persisted before the exclusion existed - delete_folder/rename_folder refuse the recipes dir and any ancestor of it with code "protected" - the delete modal renders a protected state (confirm hidden, per-row checkbox disabled) instead of a deletable 'no models' prediction - RecipeScanner.recipes_dir now shares the side-effect-free resolver in py/utils/recipes_paths.py
This commit is contained in:
@@ -1416,6 +1416,8 @@
|
|||||||
"missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.",
|
"missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.",
|
||||||
"symlinkTitle": "Folder is a symbolic link",
|
"symlinkTitle": "Folder is a symbolic link",
|
||||||
"symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.",
|
"symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.",
|
||||||
|
"protectedTitle": "Folder is protected",
|
||||||
|
"protectedMessage": "This folder holds the recipe library and cannot be deleted from LoRA Manager.",
|
||||||
"busyTitle": "A deletion is still pending",
|
"busyTitle": "A deletion is still pending",
|
||||||
"checking": "Checking the folder contents...",
|
"checking": "Checking the folder contents...",
|
||||||
"confirm": "Delete folder",
|
"confirm": "Delete folder",
|
||||||
@@ -1458,6 +1460,7 @@
|
|||||||
"busyStatus": "a deletion is still pending",
|
"busyStatus": "a deletion is still pending",
|
||||||
"missingStatus": "no longer exists on disk",
|
"missingStatus": "no longer exists on disk",
|
||||||
"symlinkStatus": "symbolic link — cannot be deleted here",
|
"symlinkStatus": "symbolic link — cannot be deleted here",
|
||||||
|
"protectedStatus": "holds the recipe library — cannot be deleted here",
|
||||||
"unknownStatus": "could not be checked",
|
"unknownStatus": "could not be checked",
|
||||||
"deletedStatus": "deleted"
|
"deletedStatus": "deleted"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
|||||||
|
|
||||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
|
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
|
||||||
|
from ..utils.recipes_paths import path_is_or_contains_recipes_dir
|
||||||
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
|
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||||
@@ -697,6 +698,19 @@ class ModelMoveService:
|
|||||||
"error": "The library root itself cannot be deleted",
|
"error": "The library root itself cannot be deleted",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if path_is_or_contains_recipes_dir(absolute_path):
|
||||||
|
# The recipes dir holds no model weight files, so the
|
||||||
|
# model_count check below cannot protect it — but removing it
|
||||||
|
# wipes the user's recipe library.
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "protected",
|
||||||
|
"error": (
|
||||||
|
"This folder holds the recipe library and cannot be "
|
||||||
|
"deleted from here"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
manifest = self._collect_folder_manifest(absolute_path)
|
manifest = self._collect_folder_manifest(absolute_path)
|
||||||
|
|
||||||
if manifest["pending_delete_job"]:
|
if manifest["pending_delete_job"]:
|
||||||
@@ -948,6 +962,18 @@ class ModelMoveService:
|
|||||||
"error": "The library root itself cannot be renamed",
|
"error": "The library root itself cannot be renamed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if path_is_or_contains_recipes_dir(absolute_path):
|
||||||
|
# Renaming the recipes dir (or an ancestor) strands the recipe
|
||||||
|
# library: the scanner keeps looking at the configured path.
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "protected",
|
||||||
|
"error": (
|
||||||
|
"This folder holds the recipe library and cannot be "
|
||||||
|
"renamed from here"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
previous_relative = self._calculate_relative_folder(absolute_path)
|
previous_relative = self._calculate_relative_folder(absolute_path)
|
||||||
target = os.path.join(os.path.dirname(absolute_path), new_name)
|
target = os.path.join(os.path.dirname(absolute_path), new_name)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ from ..utils.sidecar_paths import (
|
|||||||
resolve_centralized_dir_for_dir,
|
resolve_centralized_dir_for_dir,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import resolve_license_info
|
from ..utils.civitai_utils import resolve_license_info
|
||||||
|
from ..utils.recipes_paths import (
|
||||||
|
get_effective_recipes_dir,
|
||||||
|
normalized_recipes_dir_key,
|
||||||
|
)
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
from .model_hash_index import ModelHashIndex
|
from .model_hash_index import ModelHashIndex
|
||||||
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
||||||
@@ -72,6 +76,11 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
|
|||||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_entry_key(parent: str, name: str) -> str:
|
||||||
|
"""normcase+abspath key for a directory entry, for reserved-dir checks."""
|
||||||
|
return os.path.normcase(os.path.abspath(os.path.join(parent, name)))
|
||||||
|
|
||||||
|
|
||||||
def _file_name_stem(file_path: str) -> str:
|
def _file_name_stem(file_path: str) -> str:
|
||||||
"""Return the extension-free file name of a normalized model path.
|
"""Return the extension-free file name of a normalized model path.
|
||||||
|
|
||||||
@@ -577,6 +586,9 @@ def _walk_root_for_reconcile(
|
|||||||
stale_seen: Set[str] = set()
|
stale_seen: Set[str] = set()
|
||||||
files_since_report = 0
|
files_since_report = 0
|
||||||
walk_start = walk_path or root_path
|
walk_start = walk_path or root_path
|
||||||
|
# The recipe library sits inside a lora root by default; it is a reserved
|
||||||
|
# directory, never a model folder, so the walk must not descend into it.
|
||||||
|
recipes_dir_key = normalized_recipes_dir_key()
|
||||||
|
|
||||||
def _on_walk_error(error: OSError) -> None:
|
def _on_walk_error(error: OSError) -> None:
|
||||||
"""Record a directory the walk could not enter (offline/denied)."""
|
"""Record a directory the walk could not enter (offline/denied)."""
|
||||||
@@ -599,7 +611,11 @@ def _walk_root_for_reconcile(
|
|||||||
for root, dirnames, files in os.walk(
|
for root, dirnames, files in os.walk(
|
||||||
walk_start, followlinks=True, onerror=_on_walk_error
|
walk_start, followlinks=True, onerror=_on_walk_error
|
||||||
):
|
):
|
||||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
dirnames[:] = [
|
||||||
|
d
|
||||||
|
for d in dirnames
|
||||||
|
if not _is_excluded_dir(d) and _dir_entry_key(root, d) != recipes_dir_key
|
||||||
|
]
|
||||||
|
|
||||||
real_root = os.path.realpath(root)
|
real_root = os.path.realpath(root)
|
||||||
if not dir_claims.claim(real_root):
|
if not dir_claims.claim(real_root):
|
||||||
@@ -1457,6 +1473,9 @@ class ModelScanner:
|
|||||||
"""
|
"""
|
||||||
total_files = 0
|
total_files = 0
|
||||||
visited_real_paths = set()
|
visited_real_paths = set()
|
||||||
|
# The recipe library holds no model files; skip it so the progress
|
||||||
|
# estimate matches what the scan itself will walk.
|
||||||
|
recipes_dir_key = normalized_recipes_dir_key()
|
||||||
|
|
||||||
for root_path in self.get_model_roots():
|
for root_path in self.get_model_roots():
|
||||||
if not os.path.exists(root_path):
|
if not os.path.exists(root_path):
|
||||||
@@ -1480,6 +1499,8 @@ class ModelScanner:
|
|||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
if _is_excluded_dir(entry.name):
|
if _is_excluded_dir(entry.name):
|
||||||
continue
|
continue
|
||||||
|
if _dir_entry_key(path, entry.name) == recipes_dir_key:
|
||||||
|
continue
|
||||||
count_recursive(entry.path)
|
count_recursive(entry.path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
||||||
@@ -2407,8 +2428,49 @@ class ModelScanner:
|
|||||||
else:
|
else:
|
||||||
self._schedule_all_folders_backfill()
|
self._schedule_all_folders_backfill()
|
||||||
|
|
||||||
|
reserved = self._recipes_folder_rel_paths()
|
||||||
|
if reserved:
|
||||||
|
folders = {
|
||||||
|
folder
|
||||||
|
for folder in folders
|
||||||
|
if not any(
|
||||||
|
folder.casefold() == rel or folder.casefold().startswith(rel + "/")
|
||||||
|
for rel in reserved
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return sorted(folders, key=lambda x: x.lower())
|
return sorted(folders, key=lambda x: x.lower())
|
||||||
|
|
||||||
|
def _recipes_folder_rel_paths(self) -> Set[str]:
|
||||||
|
"""Casefolded library-relative names of the recipes dir, per root.
|
||||||
|
|
||||||
|
The recipe library sits inside a lora root by default; it is a
|
||||||
|
reserved directory, never a model folder. The scan walks no longer
|
||||||
|
record it, but snapshots persisted before that exclusion still carry
|
||||||
|
it, so readers filter it out here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
recipes_dir = get_effective_recipes_dir()
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
return set()
|
||||||
|
if not recipes_dir:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
recipes_abs = os.path.abspath(recipes_dir)
|
||||||
|
recipes_key = os.path.normcase(recipes_abs)
|
||||||
|
reserved: Set[str] = set()
|
||||||
|
for root in self.get_model_roots():
|
||||||
|
root_abs = os.path.abspath(root)
|
||||||
|
root_key = os.path.normcase(root_abs)
|
||||||
|
if recipes_key != root_key and not recipes_key.startswith(
|
||||||
|
root_key + os.sep
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
rel = os.path.relpath(recipes_abs, root_abs).replace(os.sep, "/")
|
||||||
|
if rel and rel != ".":
|
||||||
|
reserved.add(rel.casefold())
|
||||||
|
return reserved
|
||||||
|
|
||||||
async def add_known_folder(self, folder: str) -> None:
|
async def add_known_folder(self, folder: str) -> None:
|
||||||
"""Record a folder (and its parents) in the known folder list.
|
"""Record a folder (and its parents) in the known folder list.
|
||||||
|
|
||||||
@@ -2894,17 +2956,25 @@ class ModelScanner:
|
|||||||
"""Enumerate every directory under the model roots, live from disk.
|
"""Enumerate every directory under the model roots, live from disk.
|
||||||
|
|
||||||
Runs in a worker thread. Hidden directories (any segment starting
|
Runs in a worker thread. Hidden directories (any segment starting
|
||||||
with '.') and the pending-delete staging dir are excluded.
|
with '.'), the pending-delete staging dir and the recipes storage
|
||||||
|
dir are excluded.
|
||||||
"""
|
"""
|
||||||
discovered: Set[str] = set()
|
discovered: Set[str] = set()
|
||||||
visited_real_paths: Set[str] = set()
|
visited_real_paths: Set[str] = set()
|
||||||
|
# The recipe library is a reserved directory (see the reconcile walk).
|
||||||
|
recipes_dir_key = normalized_recipes_dir_key()
|
||||||
|
|
||||||
for root_path in self.get_model_roots():
|
for root_path in self.get_model_roots():
|
||||||
if not os.path.exists(root_path):
|
if not os.path.exists(root_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for root, dirnames, _files in os.walk(root_path, followlinks=True):
|
for root, dirnames, _files in os.walk(root_path, followlinks=True):
|
||||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
dirnames[:] = [
|
||||||
|
d
|
||||||
|
for d in dirnames
|
||||||
|
if not _is_excluded_dir(d)
|
||||||
|
and _dir_entry_key(root, d) != recipes_dir_key
|
||||||
|
]
|
||||||
# realpath is used only for symlink dedup, never for the
|
# realpath is used only for symlink dedup, never for the
|
||||||
# recorded path (business paths stay unresolved).
|
# recorded path (business paths stay unresolved).
|
||||||
real_root = os.path.realpath(root)
|
real_root = os.path.realpath(root)
|
||||||
@@ -3256,6 +3326,10 @@ class ModelScanner:
|
|||||||
processed_real_files: Set[str] = set()
|
processed_real_files: Set[str] = set()
|
||||||
visited_real_dirs: Set[str] = set()
|
visited_real_dirs: Set[str] = set()
|
||||||
discovered_folders: Set[str] = set()
|
discovered_folders: Set[str] = set()
|
||||||
|
# The recipe library sits inside a lora root by default; it is a
|
||||||
|
# reserved directory, never a model folder, so the scan must not
|
||||||
|
# descend into it.
|
||||||
|
recipes_dir_key = normalized_recipes_dir_key()
|
||||||
|
|
||||||
async def handle_progress(current_name: str = '') -> None:
|
async def handle_progress(current_name: str = '') -> None:
|
||||||
if progress_callback is None:
|
if progress_callback is None:
|
||||||
@@ -3334,6 +3408,11 @@ class ModelScanner:
|
|||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
if _is_excluded_dir(entry.name):
|
if _is_excluded_dir(entry.name):
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
_dir_entry_key(current_path, entry.name)
|
||||||
|
== recipes_dir_key
|
||||||
|
):
|
||||||
|
continue
|
||||||
# Record every directory (including empty ones) so
|
# Record every directory (including empty ones) so
|
||||||
# the folder tree can be served without a live walk.
|
# the folder tree can be served without a live walk.
|
||||||
rel_dir = os.path.relpath(
|
rel_dir = os.path.relpath(
|
||||||
|
|||||||
@@ -2676,23 +2676,11 @@ class RecipeScanner:
|
|||||||
@property
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
"""Get path to recipes directory"""
|
"""Get path to recipes directory"""
|
||||||
from .settings_manager import get_settings_manager
|
from ..utils.recipes_paths import get_effective_recipes_dir
|
||||||
|
|
||||||
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
|
recipes_dir = get_effective_recipes_dir()
|
||||||
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
|
if recipes_dir:
|
||||||
recipes_dir = os.path.abspath(
|
|
||||||
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
|
|
||||||
)
|
|
||||||
os.makedirs(recipes_dir, exist_ok=True)
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
return recipes_dir
|
|
||||||
|
|
||||||
if not config.loras_roots:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# config.loras_roots already sorted case-insensitively, use the first one
|
|
||||||
recipes_dir = os.path.join(config.loras_roots[0], "recipes")
|
|
||||||
os.makedirs(recipes_dir, exist_ok=True)
|
|
||||||
|
|
||||||
return recipes_dir
|
return recipes_dir
|
||||||
|
|
||||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Resolution of the recipe library's storage directory.
|
||||||
|
|
||||||
|
Kept side-effect free (no directory creation): model scanners and file
|
||||||
|
services consult these helpers on hot paths, where silently materializing
|
||||||
|
the folder would be a surprise. ``RecipeScanner.recipes_dir`` remains the
|
||||||
|
write endpoint and adds the ``makedirs`` itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_recipes_dir() -> str:
|
||||||
|
"""Resolve the directory recipe data lives in. May not exist on disk.
|
||||||
|
|
||||||
|
Mirrors ``RecipeScanner.recipes_dir`` minus the auto-create: the
|
||||||
|
``recipes_path`` setting wins; otherwise the default is ``recipes``
|
||||||
|
under the first configured lora root (``config.loras_roots`` is already
|
||||||
|
sorted case-insensitively). Returns ``""`` when no lora root exists.
|
||||||
|
"""
|
||||||
|
# Local imports: services -> config -> utils dependency direction must
|
||||||
|
# not pick up a utils -> services edge at module import time.
|
||||||
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
|
||||||
|
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
|
||||||
|
return os.path.abspath(
|
||||||
|
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_roots = [
|
||||||
|
path
|
||||||
|
for path in (getattr(config, "loras_roots", None) or [])
|
||||||
|
if isinstance(path, str) and path.strip()
|
||||||
|
]
|
||||||
|
if not lora_roots:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return os.path.abspath(os.path.join(lora_roots[0], "recipes"))
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_recipes_dir_key() -> str:
|
||||||
|
"""Comparison key (normcase + abspath) for the recipes dir; "" if unknown."""
|
||||||
|
try:
|
||||||
|
recipes_dir = get_effective_recipes_dir()
|
||||||
|
except Exception: # pragma: no cover - never break a scan over settings access
|
||||||
|
return ""
|
||||||
|
if not recipes_dir:
|
||||||
|
return ""
|
||||||
|
return os.path.normcase(os.path.abspath(recipes_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def path_is_or_contains_recipes_dir(path: str) -> bool:
|
||||||
|
"""True when *path* is the recipes dir or one of its ancestors.
|
||||||
|
|
||||||
|
Deleting or renaming an ancestor wipes the recipe library just the same,
|
||||||
|
so folder operations must refuse both shapes.
|
||||||
|
"""
|
||||||
|
key = normalized_recipes_dir_key()
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
candidate = os.path.normcase(os.path.abspath(path))
|
||||||
|
return candidate == key or key.startswith(candidate + os.sep)
|
||||||
@@ -1301,6 +1301,8 @@ export class SidebarManager {
|
|||||||
...this._notEmptyBlocker(error?.manifest),
|
...this._notEmptyBlocker(error?.manifest),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
} else if (error?.code === 'protected') {
|
||||||
|
this._renderDeleteFolderModal(relativePath, 'protected', options);
|
||||||
} else if (error?.code === 'busy') {
|
} else if (error?.code === 'busy') {
|
||||||
this._renderDeleteFolderModal(relativePath, 'busy', options);
|
this._renderDeleteFolderModal(relativePath, 'busy', options);
|
||||||
} else if (error?.code === 'missing') {
|
} else if (error?.code === 'missing') {
|
||||||
@@ -1458,6 +1460,9 @@ export class SidebarManager {
|
|||||||
entry.modelCount = Number(error?.manifest?.model_count) || 0;
|
entry.modelCount = Number(error?.manifest?.model_count) || 0;
|
||||||
entry.excludedCount = Number(error?.manifest?.excluded_model_count) || 0;
|
entry.excludedCount = Number(error?.manifest?.excluded_model_count) || 0;
|
||||||
entry.checked = false;
|
entry.checked = false;
|
||||||
|
} else if (error?.code === 'protected') {
|
||||||
|
entry.status = 'protected';
|
||||||
|
entry.checked = false;
|
||||||
} else if (error?.code === 'busy') {
|
} else if (error?.code === 'busy') {
|
||||||
entry.status = 'busy';
|
entry.status = 'busy';
|
||||||
entry.checked = false;
|
entry.checked = false;
|
||||||
@@ -1484,7 +1489,10 @@ export class SidebarManager {
|
|||||||
nodes.checkbox.disabled = !selectable;
|
nodes.checkbox.disabled = !selectable;
|
||||||
nodes.checkbox.checked = entry.checked;
|
nodes.checkbox.checked = entry.checked;
|
||||||
nodes.status.textContent = this._deleteFolderRowStatus(entry);
|
nodes.status.textContent = this._deleteFolderRowStatus(entry);
|
||||||
nodes.status.classList.toggle('blocked', entry.status === 'not_empty');
|
nodes.status.classList.toggle(
|
||||||
|
'blocked',
|
||||||
|
entry.status === 'not_empty' || entry.status === 'protected'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a probed copy may be selected for deletion. */
|
/** Whether a probed copy may be selected for deletion. */
|
||||||
@@ -1512,6 +1520,11 @@ export class SidebarManager {
|
|||||||
return translate('sidebar.folderRoot.busyStatus', {}, 'a deletion is still pending');
|
return translate('sidebar.folderRoot.busyStatus', {}, 'a deletion is still pending');
|
||||||
case 'missing':
|
case 'missing':
|
||||||
return translate('sidebar.folderRoot.missingStatus', {}, 'no longer exists on disk');
|
return translate('sidebar.folderRoot.missingStatus', {}, 'no longer exists on disk');
|
||||||
|
case 'protected':
|
||||||
|
return translate(
|
||||||
|
'sidebar.folderRoot.protectedStatus', {},
|
||||||
|
'holds the recipe library — cannot be deleted here'
|
||||||
|
);
|
||||||
case 'symlink':
|
case 'symlink':
|
||||||
return translate(
|
return translate(
|
||||||
'sidebar.folderRoot.symlinkStatus', {},
|
'sidebar.folderRoot.symlinkStatus', {},
|
||||||
@@ -1738,9 +1751,10 @@ export class SidebarManager {
|
|||||||
* `state` is 'confirm' (deletion may proceed), 'blocked' (models would be
|
* `state` is 'confirm' (deletion may proceed), 'blocked' (models would be
|
||||||
* cascaded over, which the backend refuses), 'busy' (a staged delete is
|
* cascaded over, which the backend refuses), 'busy' (a staged delete is
|
||||||
* still pending inside the folder), 'missing' (no root holds the node's
|
* still pending inside the folder), 'missing' (no root holds the node's
|
||||||
* directory any more) or 'symlink' (every copy is a symbolic link, which the
|
* directory any more), 'symlink' (every copy is a symbolic link, which the
|
||||||
* backend refuses to remove). `checking` keeps the confirm button disabled
|
* backend refuses to remove) or 'protected' (the folder holds the recipe
|
||||||
* while the authoritative server-side check runs.
|
* library, which the backend refuses to remove). `checking` keeps the
|
||||||
|
* confirm button disabled while the authoritative server-side check runs.
|
||||||
*
|
*
|
||||||
* This is the single-target form, used when the node maps to at most one
|
* This is the single-target form, used when the node maps to at most one
|
||||||
* directory; an ambiguous node renders the multi-root list instead (see
|
* directory; an ambiguous node renders the multi-root list instead (see
|
||||||
@@ -1816,6 +1830,14 @@ export class SidebarManager {
|
|||||||
'sidebar.deleteFolderModal.symlinkMessage', {},
|
'sidebar.deleteFolderModal.symlinkMessage', {},
|
||||||
'Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.'
|
'Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.'
|
||||||
);
|
);
|
||||||
|
} else if (state === 'protected') {
|
||||||
|
title.textContent = translate(
|
||||||
|
'sidebar.deleteFolderModal.protectedTitle', {}, 'Folder is protected'
|
||||||
|
);
|
||||||
|
message.textContent = translate(
|
||||||
|
'sidebar.deleteFolderModal.protectedMessage', {},
|
||||||
|
'This folder holds the recipe library and cannot be deleted from LoRA Manager.'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
title.textContent = translate(
|
title.textContent = translate(
|
||||||
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'
|
||||||
|
|||||||
@@ -794,6 +794,21 @@ describe('SidebarManager folder deletion', () => {
|
|||||||
expect(confirmBtn().style.display).toBe('none');
|
expect(confirmBtn().style.display).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks the delete when the folder holds the recipe library', async () => {
|
||||||
|
const protectedError = Object.assign(new Error('recipe library'), { code: 'protected' });
|
||||||
|
const apiClient = createApiClient({
|
||||||
|
deleteFolder: vi.fn().mockRejectedValue(protectedError),
|
||||||
|
});
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||||
|
|
||||||
|
await manager.showDeleteFolderModal('empty');
|
||||||
|
|
||||||
|
const modal = document.getElementById('deleteFolderModal');
|
||||||
|
expect(modal.dataset.state).toBe('protected');
|
||||||
|
expect(confirmBtn().style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the confirm button disabled until the check settles', async () => {
|
it('keeps the confirm button disabled until the check settles', async () => {
|
||||||
let release;
|
let release;
|
||||||
const apiClient = createApiClient({
|
const apiClient = createApiClient({
|
||||||
|
|||||||
@@ -628,3 +628,86 @@ async def test_rename_folder_requires_path(tmp_path: Path):
|
|||||||
result = await service.rename_folder("", "renamed")
|
result = await service.rename_folder("", "renamed")
|
||||||
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch):
|
||||||
|
"""The default recipes dir lives in the first lora root and holds no model
|
||||||
|
weight files, so the model_count check alone would let it be wiped."""
|
||||||
|
from py.config import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
target = tmp_path / "recipes"
|
||||||
|
target.mkdir()
|
||||||
|
recipe_file = target / "abc123.recipe.json"
|
||||||
|
recipe_file.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "protected"
|
||||||
|
assert recipe_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_refuses_an_ancestor_of_the_recipes_dir(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Deleting a parent of the recipes dir wipes the library just the same."""
|
||||||
|
from py.services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
recipes_dir = tmp_path / "data" / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True)
|
||||||
|
manager = get_settings_manager()
|
||||||
|
monkeypatch.setitem(manager.settings, "recipes_path", str(recipes_dir))
|
||||||
|
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(tmp_path / "data"))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "protected"
|
||||||
|
assert recipes_dir.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_allows_normal_folders_next_to_the_recipes_dir(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
from py.config import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||||
|
(tmp_path / "recipes").mkdir()
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
target = tmp_path / "recipes_backup"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch):
|
||||||
|
from py.config import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
target = tmp_path / "recipes"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(target), "recipes2")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "protected"
|
||||||
|
assert target.is_dir()
|
||||||
|
assert scanner.renamed_folders == []
|
||||||
|
|||||||
@@ -2859,3 +2859,93 @@ def test_root_display_labels_dedupe_by_parent_segments(monkeypatch):
|
|||||||
label = _root_display_labels([long_root])[long_root]
|
label = _root_display_labels([long_root])[long_root]
|
||||||
assert len(label) <= 40
|
assert len(label) <= 40
|
||||||
assert label.endswith("loras")
|
assert label.endswith("loras")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_excludes_recipes_dir_from_all_folders(tmp_path: Path, monkeypatch):
|
||||||
|
"""The default recipes dir lives under the first lora root; it is a
|
||||||
|
reserved directory and must never appear in the folder tree."""
|
||||||
|
_create_files(tmp_path)
|
||||||
|
recipes = tmp_path / "recipes"
|
||||||
|
(recipes / "sub").mkdir(parents=True)
|
||||||
|
# A stray weight file inside the recipes dir is not indexed either.
|
||||||
|
(recipes / "stray.txt").write_text("stray", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
assert not any("/recipes/" in item["file_path"] for item in cache.raw_data)
|
||||||
|
all_folders = await scanner.get_all_folders()
|
||||||
|
assert "recipes" not in all_folders
|
||||||
|
assert "recipes/sub" not in all_folders
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_folders_backfill_excludes_recipes_dir(tmp_path: Path, monkeypatch):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
(tmp_path / "recipes").mkdir()
|
||||||
|
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
cache.all_folders = None
|
||||||
|
|
||||||
|
await scanner.get_all_folders()
|
||||||
|
for _ in range(200):
|
||||||
|
if not scanner._all_folders_backfill_running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
assert scanner._all_folders_backfill_running is False
|
||||||
|
assert cache.all_folders is not None
|
||||||
|
assert "recipes" not in cache.all_folders
|
||||||
|
assert "nested" in cache.all_folders
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_folders_filters_recipes_dir_from_legacy_snapshots(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Snapshots persisted before the walk exclusion still carry the recipes
|
||||||
|
dir; readers filter it out until the next scan rewrites the list."""
|
||||||
|
_create_files(tmp_path)
|
||||||
|
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
cache.all_folders = sorted(
|
||||||
|
set(cache.all_folders or []) | {"recipes", "recipes/sub"},
|
||||||
|
key=lambda value: value.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
all_folders = await scanner.get_all_folders()
|
||||||
|
|
||||||
|
assert "recipes" not in all_folders
|
||||||
|
assert "recipes/sub" not in all_folders
|
||||||
|
assert "nested" in all_folders
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_walk_skips_the_recipes_dir(tmp_path: Path, monkeypatch):
|
||||||
|
(tmp_path / "recipes" / "sub").mkdir(parents=True)
|
||||||
|
(tmp_path / "keep").mkdir()
|
||||||
|
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||||
|
|
||||||
|
result = model_scanner._walk_root_for_reconcile(
|
||||||
|
root_path=str(tmp_path),
|
||||||
|
file_extensions={".txt"},
|
||||||
|
cached_paths=set(),
|
||||||
|
path_to_item={},
|
||||||
|
lookups=model_scanner._CachedPathLookups(set()),
|
||||||
|
dir_claims=model_scanner._RealDirClaims(),
|
||||||
|
excluded_models=set(),
|
||||||
|
is_cancelled=lambda: False,
|
||||||
|
report_progress=lambda _files_seen: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "keep" in result.discovered_folders
|
||||||
|
assert "recipes" not in result.discovered_folders
|
||||||
|
assert "recipes/sub" not in result.discovered_folders
|
||||||
|
|||||||
Reference in New Issue
Block a user