feat(sidebar): rename folders from the sidebar (#999)

Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.

Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.

Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.

The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
This commit is contained in:
Will Miao
2026-09-15 20:10:36 +08:00
parent 4938faa049
commit 9bbe57ee85
22 changed files with 1258 additions and 0 deletions
+125
View File
@@ -712,6 +712,131 @@ class ModelMoveService:
if callable(remove_known_folder):
await remove_known_folder(relative_folder)
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
"""Rename a directory inside the model library roots.
Unlike :meth:`delete_folder` this works on folders that hold models.
A rename keeps every file, so no per-model lifecycle step is bypassed:
the directory is renamed on disk and the affected folder, cache, hash
index and metadata-sidecar records are re-keyed onto the new prefix by
the scanner.
Args:
folder_path: Absolute path of the directory to rename (business
path — symlinks are not resolved)
new_name: New leaf name; a single path segment, not a path
Returns:
Dictionary with the success flag, the previous/next library-relative
folder names and whether the directory actually moved.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
new_name = str(new_name or "").strip()
if not new_name:
return {"success": False, "error": "New folder name is required"}
if new_name in (".", "..") or any(
char in new_name for char in '/\\:*?"<>|'
):
return {"success": False, "error": "Invalid characters in folder name"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
return {
"success": False,
"error": "Symlinked folders cannot be renamed",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be renamed",
}
previous_relative = self._calculate_relative_folder(absolute_path)
target = os.path.join(os.path.dirname(absolute_path), new_name)
if os.path.normpath(target) == os.path.normpath(absolute_path):
return {
"success": True,
"renamed": False,
"folder": previous_relative,
"previous_folder": previous_relative,
"folder_path": absolute_path.replace(os.sep, "/"),
}
if os.path.exists(target):
return {
"success": False,
"code": "target_exists",
"error": f"A folder named \"{new_name}\" already exists here",
}
# A staging manifest records absolute original/staged paths, so
# moving a folder that holds one would break its undo and purge.
if self._has_pending_delete_job(absolute_path):
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
}
os.rename(absolute_path, target)
new_relative = self._calculate_relative_folder(target)
await self._rename_folder_records(
previous_relative, new_relative, absolute_path, target
)
return {
"success": True,
"renamed": True,
"folder": new_relative,
"previous_folder": previous_relative,
"folder_path": target.replace(os.sep, "/"),
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error renaming folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
@staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree."""
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
return True
return False
async def _rename_folder_records(
self,
previous_relative: str,
new_relative: str,
previous_path: str,
new_path: str,
) -> None:
"""Hand the rename to the scanner so folder/cache records follow it."""
if not previous_relative or not new_relative:
return
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
if callable(rename_known_folder):
await rename_known_folder(
previous_relative,
new_relative,
previous_path=previous_path,
new_path=new_path,
)
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file
+153
View File
@@ -1562,6 +1562,159 @@ class ModelScanner:
"""Return True when *candidate* is *target* or lives below it."""
return candidate == target or candidate.startswith(f"{target}/")
@staticmethod
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
if not value:
return value
normalized = value.replace("\\", "/")
if normalized.startswith(old_prefix):
return new_prefix + normalized[len(old_prefix):]
return value
async def rename_known_folder(
self,
previous_folder: str,
new_folder: str,
*,
previous_path: str,
new_path: str,
) -> bool:
"""Re-key folder, cache and metadata records after a directory rename.
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
A rename keeps every file, so nothing may be dropped: the recorded
folder list, the affected cache entries (``file_path``/``folder``/
``preview_url``), the hash index and the on-disk metadata sidecars are
all rewritten onto the new prefix. That is what lets a folder full of
models be renamed without a rescan and without breaking per-model
bookkeeping.
Args:
previous_folder: Library-relative folder name before the rename
new_folder: Library-relative folder name after the rename
previous_path: Absolute directory path before the rename
new_path: Absolute directory path after the rename
Returns:
True when any recorded data was rewritten.
"""
previous = previous_folder.replace("\\", "/").strip("/")
current = new_folder.replace("\\", "/").strip("/")
if not previous or not current or previous == current:
return False
old_rel_prefix = f"{previous}/"
new_rel_prefix = f"{current}/"
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
cache = self._cache
if cache is None:
return False
changed = False
recorded = getattr(cache, "all_folders", None)
if recorded is not None:
rekeyed = sorted(
(
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
for entry in recorded
),
key=lambda entry: entry.lower(),
)
if rekeyed != list(recorded):
cache.all_folders = rekeyed
changed = True
excluded = getattr(self, "_excluded_models", None)
if excluded:
rekeyed_excluded = [
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
for entry in excluded
]
if rekeyed_excluded != list(excluded):
self._excluded_models = rekeyed_excluded
changed = True
touched: List[Dict[str, Any]] = []
for item in cache.raw_data or []:
folder_value = item.get("folder", "") or self._calculate_folder(
item.get("file_path", "")
)
if not self._folder_within(folder_value, previous):
continue
old_file_path = item.get("file_path", "")
if old_file_path:
cache.remove_from_version_index(item)
item["file_path"] = self._rekey_path(
old_file_path, old_abs_prefix, new_abs_prefix
)
hash_value = (item.get("sha256") or "").lower()
if hash_value:
self._hash_index.remove_by_path(old_file_path, hash_value)
self._hash_index.add_entry(
hash_value, item["file_path"], item.get("autov3") or None
)
item["folder"] = self._rekey_folder_name(
folder_value, previous, old_rel_prefix, new_rel_prefix
)
if item.get("preview_url"):
item["preview_url"] = self._rekey_path(
item["preview_url"], old_abs_prefix, new_abs_prefix
)
touched.append(item)
if touched:
changed = True
await self._rewrite_sidecar_paths(touched)
folders = set(item.get("folder", "") for item in cache.raw_data)
cache.folders = sorted(folders, key=lambda x: x.lower())
cache.rebuild_version_index()
await cache.resort()
if changed:
await self._persist_current_cache()
self.bump_cache_version()
return changed
@staticmethod
def _rekey_folder_name(
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
) -> str:
"""Move a library-relative folder name (and its subtree) under a new name."""
if entry == previous:
return new_rel_prefix.rstrip("/")
if entry.startswith(old_rel_prefix):
return new_rel_prefix + entry[len(old_rel_prefix):]
return entry
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
"""Point each model's metadata sidecar at its new location.
Sidecars travel with the renamed directory, so only the recorded
``file_path``/``preview_url`` inside them need rewriting. Failures are
logged and skipped — a stale sidecar is repaired by the next metadata
refresh, and must not abort the rename.
"""
for item in entries:
file_path = item.get("file_path")
if not file_path:
continue
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
if not os.path.exists(metadata_path):
continue
try:
await self._update_metadata_paths(metadata_path, file_path)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
)
def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running: