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
+26 -4
View File
@@ -1301,6 +1301,8 @@ export class SidebarManager {
...this._notEmptyBlocker(error?.manifest),
...options,
});
} else if (error?.code === 'protected') {
this._renderDeleteFolderModal(relativePath, 'protected', options);
} else if (error?.code === 'busy') {
this._renderDeleteFolderModal(relativePath, 'busy', options);
} else if (error?.code === 'missing') {
@@ -1458,6 +1460,9 @@ export class SidebarManager {
entry.modelCount = Number(error?.manifest?.model_count) || 0;
entry.excludedCount = Number(error?.manifest?.excluded_model_count) || 0;
entry.checked = false;
} else if (error?.code === 'protected') {
entry.status = 'protected';
entry.checked = false;
} else if (error?.code === 'busy') {
entry.status = 'busy';
entry.checked = false;
@@ -1484,7 +1489,10 @@ export class SidebarManager {
nodes.checkbox.disabled = !selectable;
nodes.checkbox.checked = entry.checked;
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. */
@@ -1512,6 +1520,11 @@ export class SidebarManager {
return translate('sidebar.folderRoot.busyStatus', {}, 'a deletion is still pending');
case 'missing':
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':
return translate(
'sidebar.folderRoot.symlinkStatus', {},
@@ -1738,9 +1751,10 @@ export class SidebarManager {
* `state` is 'confirm' (deletion may proceed), 'blocked' (models would be
* cascaded over, which the backend refuses), 'busy' (a staged delete is
* 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
* backend refuses to remove). `checking` keeps the confirm button disabled
* while the authoritative server-side check runs.
* directory any more), 'symlink' (every copy is a symbolic link, which the
* backend refuses to remove) or 'protected' (the folder holds the recipe
* 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
* directory; an ambiguous node renders the multi-root list instead (see
@@ -1816,6 +1830,14 @@ export class SidebarManager {
'sidebar.deleteFolderModal.symlinkMessage', {},
'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 {
title.textContent = translate(
'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty'