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:
Will Miao
2026-10-07 19:38:28 +08:00
parent f4d0228d6b
commit 1805519f4f
9 changed files with 397 additions and 26 deletions
+83
View File
@@ -628,3 +628,86 @@ async def test_rename_folder_requires_path(tmp_path: Path):
result = await service.rename_folder("", "renamed")
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 == []