feat(sidebar): manage folders that live under several model roots

The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.

Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
  answer which directories a library-relative folder maps to
  (folder_path/root/is_symlink), in scanner root order, skipping directories
  no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
  so the sidebar can tell "this node is stale, refresh" from a failed
  operation.

Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
  absolute paths: default root first, the old root-prefix fallback only
  while a single root is configured, and an explicit unresolved error for a
  multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
  absolute path. Several copies render one checkbox row per copy, each
  dry-run against the delete guard ("no models" / "contains N model
  file(s)..." / a deletion is still pending / no longer exists / symbolic
  link): a blocked copy is unticked, disabled and explained, the button
  reads "Delete N folders", every ticked copy is deleted and guarded on its
  own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
  ticking a box no longer rebuilds the list, steals focus or resizes the
  modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
  parent's root when the parent resolves to exactly one directory, and the
  undo restores every copy a delete removed.

i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.

Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
This commit is contained in:
Will Miao
2026-10-06 17:14:07 +08:00
parent 5ebf5aeccc
commit 2dcaf6a30e
23 changed files with 2220 additions and 180 deletions
+31 -3
View File
@@ -1409,9 +1409,16 @@
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"missingTitle": "フォルダが見つかりません",
"missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"symlinkTitle": "フォルダがシンボリックリンクです",
"symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。",
"busyTitle": "保留中の削除があります",
"checking": "フォルダの内容を確認しています...",
"confirm": "フォルダを削除"
"confirm": "フォルダを削除",
"confirmMulti": "{count} 個のフォルダを削除",
"deleting": "削除しています...",
"keptNote": "チェックを外したコピーがあると、このフォルダはサイドバーに残ります。"
},
"deleteFolderResult": {
"success": "フォルダ \"{name}\" を削除しました",
@@ -1422,7 +1429,10 @@
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。",
"successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました",
"partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
},
"renameFolder": "フォルダ名を変更",
"renameFolderResult": {
@@ -1431,7 +1441,25 @@
"targetExists": "同じ名前のフォルダが既に存在します",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダ名を変更できません",
"noRoot": "モデルルートが設定されていません"
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"symlink": "このフォルダはシンボリックリンクです。サイドバーからはフォルダ名を変更できません。"
},
"folderRoot": {
"chooseTitle": "このフォルダは複数のモデルルートに存在します",
"symlinkOption": "シンボリックリンク",
"rootsHint": "このフォルダを削除するモデルルートを選択してください:",
"checkingStatus": "確認しています...",
"okStatus": "モデルなし",
"notEmptyStatus": "モデルファイルが {count} 個あります —— 先に削除するか移動してください",
"notEmptyExcludedStatus": "モデルファイルが {count} 個あり、そのうち {excluded} 個はライブラリから除外されています",
"busyStatus": "保留中の削除があります",
"missingStatus": "ディスク上に存在しません",
"symlinkStatus": "シンボリックリンク —— ここからは削除できません",
"unknownStatus": "確認できませんでした",
"deletedStatus": "削除しました"
},
"folderResult": {
"unresolved": "このフォルダがどのモデルルートにあるかを特定できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
},
"dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。",