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
@@ -794,6 +794,21 @@ describe('SidebarManager folder deletion', () => {
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 () => {
let release;
const apiClient = createApiClient({
+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 == []
+90
View File
@@ -2859,3 +2859,93 @@ def test_root_display_labels_dedupe_by_parent_segments(monkeypatch):
label = _root_display_labels([long_root])[long_root]
assert len(label) <= 40
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