From 9bbe57ee8562cf799fe3191823ce7064cc1e75d9 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 15 Sep 2026 20:10:36 +0800 Subject: [PATCH] feat(sidebar): rename folders from the sidebar (#999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- locales/de.json | 9 + locales/en.json | 9 + locales/es.json | 9 + locales/fr.json | 9 + locales/he.json | 9 + locales/ja.json | 9 + locales/ko.json | 9 + locales/ru.json | 9 + locales/zh-CN.json | 9 + locales/zh-TW.json | 9 + py/routes/handlers/model_handlers.py | 34 +++ py/routes/model_route_registrar.py | 1 + py/services/model_file_service.py | 125 ++++++++++ py/services/model_scanner.py | 153 ++++++++++++ static/js/api/apiConfig.js | 1 + static/js/api/baseModelApi.js | 31 +++ static/js/components/SidebarManager.js | 222 ++++++++++++++++++ templates/components/context_menu.html | 3 + .../sidebarManager.folderManagement.test.js | 169 +++++++++++++ tests/routes/test_model_move_handler.py | 119 ++++++++++ tests/services/test_model_file_service.py | 155 ++++++++++++ tests/services/test_model_scanner.py | 155 ++++++++++++ 22 files changed, 1258 insertions(+) diff --git a/locales/de.json b/locales/de.json index 94ee7bb1..c0526dbb 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", diff --git a/locales/en.json b/locales/en.json index b2c5f4cb..19988d29 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1283,6 +1283,15 @@ "unsupported": "Folder deletion is not supported on this page", "noRoot": "No model root is configured" }, + "renameFolder": "Rename folder", + "renameFolderResult": { + "success": "Folder renamed to \"{name}\"", + "failed": "Failed to rename folder: {message}", + "targetExists": "A folder with that name already exists here", + "busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "Folder renaming is not supported on this page", + "noRoot": "No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Unable to determine destination path for move.", "moveUnsupported": "Move is not supported for this item.", diff --git a/locales/es.json b/locales/es.json index 4ead463a..20c08fc9 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "moveUnsupported": "El movimiento no es compatible con este elemento.", diff --git a/locales/fr.json b/locales/fr.json index 37634a55..41f1acf4 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.", diff --git a/locales/he.json b/locales/he.json index ee6308c5..cc3bf8e3 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", diff --git a/locales/ja.json b/locales/ja.json index fbb7dc3d..89cdf684 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "移動先のパスを特定できません。", "moveUnsupported": "この項目の移動はサポートされていません。", diff --git a/locales/ko.json b/locales/ko.json index 248802e1..b61b791e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", diff --git a/locales/ru.json b/locales/ru.json index bbe986ae..70af3190 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "moveUnsupported": "Перемещение этого элемента не поддерживается.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 20f60d4f..9442b3e1 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "无法确定移动的目标路径。", "moveUnsupported": "此条目不支持移动。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index b84ea0d2..56b0b01f 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1283,6 +1283,15 @@ "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "renameFolder": "[TODO: Translate] Rename folder", + "renameFolderResult": { + "success": "[TODO: Translate] Folder renamed to \"{name}\"", + "failed": "[TODO: Translate] Failed to rename folder: {message}", + "targetExists": "[TODO: Translate] A folder with that name already exists here", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder renaming is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "無法確定移動的目標路徑。", "moveUnsupported": "此項目不支援移動。", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 68b68c9b..27b6f2ad 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2530,6 +2530,39 @@ class ModelMoveHandler: self._logger.error("Error deleting folder: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def rename_folder(self, request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) + try: + folder_path = data.get("folder_path") + new_name = data.get("new_name") + if not folder_path: + return web.json_response( + {"success": False, "error": "Folder path is required"}, status=400 + ) + if not new_name: + return web.json_response( + {"success": False, "error": "New folder name is required"}, status=400 + ) + result = await self._move_service.rename_folder(folder_path, new_name) + if result.get("success"): + if result.get("renamed"): + _broadcast_models_changed() + return web.json_response(result, status=200) + + # A name collision or a staged delete inside the subtree is a + # conflict with the state the client rendered, not a bad request. + code = result.get("code") + status = 409 if code in ("target_exists", "busy") else 400 + return web.json_response(result, status=status) + except Exception as exc: + self._logger.error("Error renaming folder: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def move_model(self, request: web.Request) -> web.Response: try: data = await request.json() @@ -3482,6 +3515,7 @@ class ModelHandlerSet: "move_models_bulk": self.move.move_models_bulk, "create_folder": self.move.create_folder, "delete_folder": self.move.delete_folder, + "rename_folder": self.move.rename_folder, "auto_organize_models": self.auto_organize.auto_organize_models, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_model_notes": self.query.get_model_notes, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index c00472da..ca07137b 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -42,6 +42,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"), RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"), + RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition( diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 87d12828..23855993 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -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 diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 3864e9e5..e470dbd2 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -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: diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index d6aa33b5..ae10e80f 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -85,6 +85,7 @@ export function getApiEndpoints(modelType) { moveBulk: `/api/lm/${modelType}/move_models_bulk`, createFolder: `/api/lm/${modelType}/create-folder`, deleteFolder: `/api/lm/${modelType}/delete-folder`, + renameFolder: `/api/lm/${modelType}/rename-folder`, // CivitAI integration fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 682de035..99f999e3 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1364,6 +1364,37 @@ export class BaseModelApiClient { return result; } + /** + * Rename a folder inside the library roots. + * + * Works on folders that hold models too — the backend re-keys the affected + * cache records instead of cascading. A name collision or a staged delete + * inside the subtree surfaces as a 409 conflict, attached to the thrown + * Error as `code`. + * + * @param {string} folderPath Absolute business path of the folder + * @param {string} newName New leaf name (a single path segment) + */ + async renameFolder(folderPath, newName) { + const response = await fetch(this.apiConfig.endpoints.renameFolder, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ folder_path: folderPath, new_name: newName }) + }); + + const result = await response.json().catch(() => ({})); + + if (!response.ok || result.success === false) { + const error = new Error(result.error || `Failed to rename folder`); + error.code = result.code || null; + throw error; + } + + return result; + } + async fetchUnifiedFolderTree(options = {}) { try { const { includeEmpty = false } = options; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 95daadf7..10214b35 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -47,6 +47,8 @@ export class SidebarManager { this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes this._createFolderBasePath = null; this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation + this._renameFolderPath = null; + this._renameFolderNode = null; this._pendingDeleteFolderPath = null; this._deleteFolderModalWired = false; @@ -118,6 +120,7 @@ export class SidebarManager { this.clearAllDropHighlights(); this.resetDragState(); this.hideCreateFolderInput(); + this.hideRenameFolderInput(); this.hideSidebarHiddenIndicator(); @@ -136,6 +139,8 @@ export class SidebarManager { this.nonEmptyFolders = null; this._createFolderBasePath = null; this._createFolderTempChildren = null; + this._renameFolderPath = null; + this._renameFolderNode = null; this._pendingDeleteFolderPath = null; // Reset container margin @@ -763,6 +768,214 @@ export class SidebarManager { this.hideCreateFolderInput(); } + // ===== Folder rename (inline row, file-explorer style) ===== + + /** + * Turn the folder node at *path* into an editable row. + * + * Mirrors the create-folder inline row (Enter confirms, Escape/blur + * cancels) but is inserted where the node sits and hides that node while + * editing, so the tree does not jump. + */ + showRenameFolderInput(path) { + if (!path) return; + + this.hideRenameFolderInput(); + + const folderTree = document.getElementById('sidebarFolderTree'); + if (!folderTree) return; + + const node = this._findFolderNodeElement(folderTree, path); + if (!node) return; + + const row = this._buildRenameFolderRow(this._folderLeafName(path)); + node.parentElement.insertBefore(row, node); + node.style.display = 'none'; + + this._renameFolderNode = node; + this._renameFolderPath = path; + + const input = row.querySelector('.sidebar-rename-folder-input'); + if (!input) return; + input.focus(); + input.select(); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + this.handleRenameFolderSubmit(); + } else if (e.key === 'Escape') { + this.handleRenameFolderCancel(); + } + }); + // Clicking away cancels, mirroring the create-folder row + input.addEventListener('blur', () => { + setTimeout(() => { + if (this._renameFolderPath) { + this.handleRenameFolderCancel(); + } + }, 100); + }); + } + + _buildRenameFolderRow(currentName) { + const isListMode = this.displayMode === 'list'; + const row = document.createElement('div'); + row.id = 'sidebarRenameFolderInput'; + row.className = 'sidebar-create-folder-node sidebar-rename-folder-node'; + row.innerHTML = ` + + `; + return row; + } + + _findFolderNodeElement(folderTree, path) { + return [...folderTree.querySelectorAll('.sidebar-tree-node, .sidebar-folder-item')] + .find(element => element.dataset.path === path) || null; + } + + _folderLeafName(path) { + if (!path) return ''; + const index = path.lastIndexOf('/'); + return index === -1 ? path : path.slice(index + 1); + } + + hideRenameFolderInput() { + // Clear the flag first so the input's blur handler does not treat + // removing the row as a cancel. + this._renameFolderPath = null; + + const row = document.getElementById('sidebarRenameFolderInput'); + if (row) { + row.remove(); + } + + const node = this._renameFolderNode; + this._renameFolderNode = null; + if (node && node.isConnected) { + node.style.display = ''; + } + } + + handleRenameFolderCancel() { + this.hideRenameFolderInput(); + } + + async handleRenameFolderSubmit() { + const input = document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input'); + const path = this._renameFolderPath; + if (!input || !path) { + return; + } + + const newName = input.value.trim(); + if (!newName) { + showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning'); + return; + } + + if (/[\\/:*?"<>|]/.test(newName)) { + showToast('sidebar.dragDrop.invalidFolderName', {}, 'error'); + return; + } + + this.hideRenameFolderInput(); + + if (newName === this._folderLeafName(path)) { + return; + } + + await this._renameFolder(path, newName); + } + + async _renameFolder(relativePath, newName) { + if (!this._supportsFolderManagement() || typeof this.apiClient.renameFolder !== 'function') { + showToast('sidebar.renameFolderResult.unsupported', {}, 'error'); + return false; + } + + try { + const rootsData = await this.apiClient.fetchModelRoots(); + const roots = rootsData?.roots || []; + const root = this._resolveDefaultRoot(roots); + if (!root) { + showToast('sidebar.renameFolderResult.noRoot', {}, 'error'); + return false; + } + + const absolutePath = this.combineRootAndRelativePath(root, relativePath); + const result = await this.apiClient.renameFolder(absolutePath, newName); + + // Carry the user's place across the rename: the persisted + // selection and the expanded set would otherwise point at a folder + // the refreshed tree no longer contains. + const newPath = result.folder || this._siblingFolderPath(relativePath, newName); + this._rekeyFolderPath(relativePath, newPath); + + await this.refresh(); + + showToast('sidebar.renameFolderResult.success', { name: newName }, 'success'); + return true; + } catch (error) { + console.error('[SidebarManager] Error renaming folder:', error); + if (error?.code === 'target_exists') { + showToast('sidebar.renameFolderResult.targetExists', {}, 'warning'); + } else if (error?.code === 'busy') { + showToast('sidebar.renameFolderResult.busy', {}, 'warning'); + } else { + showToast( + 'sidebar.renameFolderResult.failed', + { message: error?.message || 'Unknown error' }, + 'error' + ); + } + return false; + } + } + + _siblingFolderPath(relativePath, newName) { + const index = relativePath.lastIndexOf('/'); + const parent = index === -1 ? '' : relativePath.slice(0, index); + return parent ? `${parent}/${newName}` : newName; + } + + _rekeyFolderPath(previousPath, newPath) { + if (!previousPath || !newPath || previousPath === newPath) return; + + const prefix = `${previousPath}/`; + const newPrefix = `${newPath}/`; + const rekey = (value) => { + if (value === previousPath) return newPath; + if (value.startsWith(prefix)) return newPrefix + value.slice(prefix.length); + return value; + }; + + if (this.expandedNodes.size > 0) { + this.expandedNodes = new Set([...this.expandedNodes].map(rekey)); + this.saveExpandedState(); + } + + if (this.selectedPath) { + const rekeyed = rekey(this.selectedPath); + if (rekeyed !== this.selectedPath) { + this.selectedPath = rekeyed; + if (this.pageControls?.pageState) { + this.pageControls.pageState.activeFolder = rekeyed; + } + setStorageItem(`${this.pageType}_activeFolder`, rekeyed); + } + } + } + /** * Open the folder delete modal for *path*. * @@ -1435,6 +1648,12 @@ export class SidebarManager { deleteItem.style.display = this._supportsFolderManagement() ? '' : 'none'; } + // Renaming an on-disk folder is likewise library-only. + const renameItem = menu.querySelector('[data-action="rename-folder"]'); + if (renameItem) { + renameItem.style.display = this._supportsFolderManagement() ? '' : 'none'; + } + menu.style.left = `${x}px`; menu.style.top = `${y}px`; menu.style.display = 'block'; @@ -1483,6 +1702,9 @@ export class SidebarManager { case 'create-subfolder': this.showCreateFolderInput(path); break; + case 'rename-folder': + this.showRenameFolderInput(path); + break; case 'delete-folder': this.showDeleteFolderModal(path); break; diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index d2e1d7ae..9e5bd2f1 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -209,6 +209,9 @@
{{ t('sidebar.newSubfolder') }}
+
+ {{ t('sidebar.renameFolder') }} +
{{ t('sidebar.folderUpdateCheck.label') }}
diff --git a/tests/frontend/components/sidebarManager.folderManagement.test.js b/tests/frontend/components/sidebarManager.folderManagement.test.js index 2358c3f5..bfc5d636 100644 --- a/tests/frontend/components/sidebarManager.folderManagement.test.js +++ b/tests/frontend/components/sidebarManager.folderManagement.test.js @@ -59,6 +59,12 @@ function createApiClient(overrides = {}) { dir_count: 0, restorable: true, }), + renameFolder: vi.fn().mockResolvedValue({ + success: true, + renamed: true, + folder: 'renamed', + previous_folder: 'empty', + }), ...overrides, }; } @@ -677,3 +683,166 @@ describe('SidebarManager folder deletion', () => { }); }); +describe('SidebarManager folder rename', () => { + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = '
'; + state.global.settings = {}; + vi.clearAllMocks(); + }); + + function renameInput() { + return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input'); + } + + it('turns the node into a prefilled inline row in tree mode', () => { + const manager = createManager(createApiClient()); + manager.treeData = { characters: { anime: {} } }; + manager.renderTree(); + + manager.showRenameFolderInput('characters/anime'); + + const row = document.getElementById('sidebarRenameFolderInput'); + expect(row).not.toBeNull(); + expect(renameInput().value).toBe('anime'); + // The node is hidden in place, not removed: the row sits right before it + const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]'); + expect(node.style.display).toBe('none'); + expect(row.nextElementSibling).toBe(node); + expect(manager._renameFolderPath).toBe('characters/anime'); + }); + + it('inserts the row in place in list mode', () => { + const manager = createManager(createApiClient(), { displayMode: 'list' }); + manager.foldersList = ['characters', 'characters/anime']; + manager.renderFolderList(); + + manager.showRenameFolderInput('characters/anime'); + + const row = document.getElementById('sidebarRenameFolderInput'); + expect(row.querySelector('.sidebar-node-content')).not.toBeNull(); + const item = document.querySelector('.sidebar-folder-item[data-path="characters/anime"]'); + expect(row.nextElementSibling).toBe(item); + }); + + it('restores the node when the edit is canceled', () => { + const manager = createManager(createApiClient()); + manager.treeData = { characters: { anime: {} } }; + manager.renderTree(); + + manager.showRenameFolderInput('characters/anime'); + manager.handleRenameFolderCancel(); + + expect(document.getElementById('sidebarRenameFolderInput')).toBeNull(); + expect(manager._renameFolderPath).toBeNull(); + const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]'); + expect(node.style.display).toBe(''); + }); + + it('renames through the API and re-keys the persisted selection', async () => { + const apiClient = createApiClient(); + const manager = createManager(apiClient); + manager.refresh = vi.fn().mockResolvedValue(undefined); + manager.selectedPath = 'characters/anime'; + manager.expandedNodes = new Set(['characters', 'characters/anime']); + manager.pageControls = { pageState: { activeFolder: 'characters/anime' } }; + + const success = await manager._renameFolder('characters/anime', 'animation'); + + expect(success).toBe(true); + expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/characters/anime', 'animation'); + expect(manager.selectedPath).toBe('renamed'); + expect(manager.pageControls.pageState.activeFolder).toBe('renamed'); + expect(getStorageItem('loras_activeFolder')).toBe('renamed'); + expect(manager.refresh).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith( + 'sidebar.renameFolderResult.success', { name: 'animation' }, 'success' + ); + }); + + it('re-keys the expanded subtree and the selection', () => { + const manager = createManager(createApiClient()); + manager.expandedNodes = new Set(['a', 'a/b', 'a/b/c', 'x']); + manager.selectedPath = 'a/b/c'; + manager.saveExpandedState = vi.fn(); + + manager._rekeyFolderPath('a/b', 'a/z'); + + expect([...manager.expandedNodes]).toEqual(['a', 'a/z', 'a/z/c', 'x']); + expect(manager.selectedPath).toBe('a/z/c'); + expect(manager.saveExpandedState).toHaveBeenCalledTimes(1); + }); + + it('submits the inline edit and skips the API for an unchanged name', async () => { + const apiClient = createApiClient(); + const manager = createManager(apiClient); + manager.refresh = vi.fn().mockResolvedValue(undefined); + manager.treeData = { characters: { anime: {} } }; + manager.renderTree(); + + manager.showRenameFolderInput('characters/anime'); + renameInput().value = 'anime'; + await manager.handleRenameFolderSubmit(); + + expect(apiClient.renameFolder).not.toHaveBeenCalled(); + expect(document.getElementById('sidebarRenameFolderInput')).toBeNull(); + }); + + it('rejects invalid names before calling the API', async () => { + const apiClient = createApiClient(); + const manager = createManager(apiClient); + manager.treeData = { characters: { anime: {} } }; + manager.renderTree(); + + manager.showRenameFolderInput('characters/anime'); + renameInput().value = 'bad/name'; + await manager.handleRenameFolderSubmit(); + + expect(apiClient.renameFolder).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith('sidebar.dragDrop.invalidFolderName', {}, 'error'); + // The row stays open so the name can be corrected + expect(document.getElementById('sidebarRenameFolderInput')).not.toBeNull(); + }); + + it('surfaces a name collision', async () => { + const conflict = Object.assign(new Error('already exists'), { code: 'target_exists' }); + const apiClient = createApiClient({ + renameFolder: vi.fn().mockRejectedValue(conflict), + }); + const manager = createManager(apiClient); + manager.refresh = vi.fn().mockResolvedValue(undefined); + + const success = await manager._renameFolder('characters/anime', 'animation'); + + expect(success).toBe(false); + expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.targetExists', {}, 'warning'); + expect(manager.refresh).not.toHaveBeenCalled(); + }); + + it('routes the context-menu action to the inline rename row', () => { + const manager = createManager(createApiClient()); + manager.showRenameFolderInput = vi.fn(); + + manager._performFolderAction('rename-folder', 'characters/anime'); + + expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime'); + }); + + it('hides the rename entry when folder management is unsupported', () => { + document.body.insertAdjacentHTML('beforeend', ` +
+
+
`); + const apiClient = createApiClient(); + apiClient.apiConfig.config.supportsFolderManagement = false; + const manager = createManager(apiClient); + + manager._showFolderContextMenu(10, 10, 'empty'); + + const item = document.querySelector('#sidebarFolderContextMenu [data-action="rename-folder"]'); + expect(item.style.display).toBe('none'); + + manager._closeFolderContextMenu(); + }); +}); + diff --git a/tests/routes/test_model_move_handler.py b/tests/routes/test_model_move_handler.py index 595cdbbc..b37640a7 100644 --- a/tests/routes/test_model_move_handler.py +++ b/tests/routes/test_model_move_handler.py @@ -11,6 +11,7 @@ class FakeMoveService: self._result = result self.received_path = None self.received_dry_run = None + self.received_new_name = None async def create_folder(self, folder_path): self.received_path = folder_path @@ -21,6 +22,11 @@ class FakeMoveService: self.received_dry_run = dry_run return self._result + async def rename_folder(self, folder_path, new_name): + self.received_path = folder_path + self.received_new_name = new_name + return self._result + class FakeRequest: def __init__(self, payload): @@ -212,3 +218,116 @@ async def test_delete_folder_invalid_json_body(): assert response.status == 400 assert json.loads(response.text)["success"] is False + + +@pytest.mark.asyncio +async def test_rename_folder_success(): + handler, service = _make_handler( + { + "success": True, + "renamed": True, + "folder": "characters/animation", + "previous_folder": "characters/anime", + } + ) + + response = await handler.rename_folder( + FakeRequest( + {"folder_path": "/library/characters/anime", "new_name": "animation"} + ) + ) + + assert response.status == 200 + payload = json.loads(response.text) + assert payload["success"] is True + assert payload["folder"] == "characters/animation" + assert service.received_path == "/library/characters/anime" + assert service.received_new_name == "animation" + + +@pytest.mark.asyncio +async def test_rename_folder_missing_path(): + handler, service = _make_handler({"success": True}) + + response = await handler.rename_folder(FakeRequest({"new_name": "animation"})) + + assert response.status == 400 + assert json.loads(response.text)["success"] is False + assert service.received_path is None + + +@pytest.mark.asyncio +async def test_rename_folder_missing_name(): + handler, service = _make_handler({"success": True}) + + response = await handler.rename_folder( + FakeRequest({"folder_path": "/library/characters/anime"}) + ) + + assert response.status == 400 + payload = json.loads(response.text) + assert payload["success"] is False + assert service.received_new_name is None + + +@pytest.mark.asyncio +async def test_rename_folder_target_exists_maps_to_409(): + handler, _service = _make_handler( + { + "success": False, + "code": "target_exists", + "error": 'A folder named "animation" already exists here', + } + ) + + response = await handler.rename_folder( + FakeRequest( + {"folder_path": "/library/characters/anime", "new_name": "animation"} + ) + ) + + assert response.status == 409 + payload = json.loads(response.text) + assert payload["code"] == "target_exists" + + +@pytest.mark.asyncio +async def test_rename_folder_busy_maps_to_409(): + handler, _service = _make_handler( + {"success": False, "code": "busy", "error": "staged delete pending"} + ) + + response = await handler.rename_folder( + FakeRequest({"folder_path": "/library/full", "new_name": "renamed"}) + ) + + assert response.status == 409 + assert json.loads(response.text)["code"] == "busy" + + +@pytest.mark.asyncio +async def test_rename_folder_invalid_name_maps_to_400(): + handler, _service = _make_handler( + {"success": False, "error": "Invalid characters in folder name"} + ) + + response = await handler.rename_folder( + FakeRequest({"folder_path": "/library/full", "new_name": "a/b"}) + ) + + assert response.status == 400 + assert json.loads(response.text)["success"] is False + + +@pytest.mark.asyncio +async def test_rename_folder_invalid_json_body(): + class BadJsonRequest: + async def json(self): + raise ValueError("bad json") + + handler, _service = _make_handler({"success": True}) + + response = await handler.rename_folder(BadJsonRequest()) + + assert response.status == 400 + assert json.loads(response.text)["success"] is False diff --git a/tests/services/test_model_file_service.py b/tests/services/test_model_file_service.py index cad5f264..02422575 100644 --- a/tests/services/test_model_file_service.py +++ b/tests/services/test_model_file_service.py @@ -15,6 +15,7 @@ class FakeScanner: self._roots = [str(root) for root in roots] self.known_folders: List[str] = [] self.removed_folders: List[str] = [] + self.renamed_folders: List[tuple] = [] def get_model_roots(self) -> List[str]: return list(self._roots) @@ -25,6 +26,9 @@ class FakeScanner: async def remove_known_folder(self, folder: str) -> None: self.removed_folders.append(folder) + async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None: + self.renamed_folders.append((previous, current, kwargs)) + @pytest.mark.asyncio async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path): @@ -271,3 +275,154 @@ async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_p # The linked model is not part of the subtree being deleted assert result["model_count"] == 0 assert (real / "model.safetensors").exists() + + +@pytest.mark.asyncio +async def test_rename_folder_moves_directory_and_forwards_rekey(tmp_path: Path): + target = _make_nested(tmp_path) + (target / "model.safetensors").write_text("weights", encoding="utf-8") + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(target), "animation") + + renamed = tmp_path / "characters" / "animation" + assert result["success"] is True + assert result["renamed"] is True + assert result["folder"] == "characters/animation" + assert result["previous_folder"] == "characters/anime" + assert renamed.is_dir() + assert (renamed / "model.safetensors").exists() + assert not target.exists() + + previous, current, kwargs = scanner.renamed_folders[0] + assert previous == "characters/anime" + assert current == "characters/animation" + assert kwargs["previous_path"] == target.as_posix() + assert kwargs["new_path"] == renamed.as_posix() + + +@pytest.mark.asyncio +async def test_rename_folder_noop_when_name_is_unchanged(tmp_path: Path): + target = _make_nested(tmp_path) + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(target), "anime") + + assert result["success"] is True + assert result["renamed"] is False + assert target.is_dir() + assert scanner.renamed_folders == [] + + +@pytest.mark.asyncio +async def test_rename_folder_refuses_existing_target(tmp_path: Path): + target = _make_nested(tmp_path) + (tmp_path / "characters" / "animation").mkdir() + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(target), "animation") + + assert result["success"] is False + assert result["code"] == "target_exists" + assert target.is_dir() + assert scanner.renamed_folders == [] + + +@pytest.mark.parametrize("new_name", ["", " ", "a/b", "..", ".", "bad:name", "back\\slash"]) +@pytest.mark.asyncio +async def test_rename_folder_rejects_invalid_names(tmp_path: Path, new_name: str): + target = _make_nested(tmp_path) + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(target), new_name) + + assert result["success"] is False + assert target.is_dir() + assert scanner.renamed_folders == [] + + +@pytest.mark.asyncio +async def test_rename_folder_refuses_the_library_root_itself(tmp_path: Path): + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(tmp_path), "renamed-root") + + assert result["success"] is False + assert "root" in result["error"].lower() + assert tmp_path.is_dir() + + +@pytest.mark.asyncio +async def test_rename_folder_rejects_paths_outside_roots(tmp_path: Path): + root = tmp_path / "library" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + scanner = FakeScanner([root]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(outside), "renamed") + + assert result["success"] is False + assert outside.is_dir() + assert scanner.renamed_folders == [] + + +@pytest.mark.asyncio +async def test_rename_folder_reports_missing_directory(tmp_path: Path): + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(tmp_path / "gone"), "renamed") + + assert result["success"] is False + assert "no longer exists" in result["error"] + + +@pytest.mark.asyncio +async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path): + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + try: + link.symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): # pragma: no cover - platform guard + pytest.skip("symlinks are not supported on this platform") + + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(link), "renamed") + + assert result["success"] is False + assert "symlink" in result["error"].lower() + assert link.is_symlink() + + +@pytest.mark.asyncio +async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path): + target = _make_nested(tmp_path) + (target / ".lm-pending-delete").mkdir() + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.rename_folder(str(target), "animation") + + assert result["success"] is False + assert result["code"] == "busy" + assert target.is_dir() + assert scanner.renamed_folders == [] + + +@pytest.mark.asyncio +async def test_rename_folder_requires_path(tmp_path: Path): + service = ModelMoveService(FakeScanner([tmp_path]), "lora") + + result = await service.rename_folder("", "renamed") + + assert result["success"] is False diff --git a/tests/services/test_model_scanner.py b/tests/services/test_model_scanner.py index 40aad4ca..3a57109c 100644 --- a/tests/services/test_model_scanner.py +++ b/tests/services/test_model_scanner.py @@ -1609,6 +1609,161 @@ async def test_remove_known_folder_ignores_empty_input(tmp_path: Path): assert cache.all_folders == before +@pytest.mark.asyncio +async def test_rename_known_folder_rekeys_folders_cache_and_sidecar(tmp_path: Path): + _, second, _ = _create_files(tmp_path) + nested = tmp_path / "nested" + preview = nested / "two.preview.png" + preview.write_text("png", encoding="utf-8") + (nested / "two.metadata.json").write_text( + json.dumps( + { + "file_path": _normalize_path(second), + "preview_url": _normalize_path(preview), + } + ), + encoding="utf-8", + ) + + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + cache = await scanner.get_cached_data() + + entry = next(item for item in cache.raw_data if item["model_name"] == "two") + entry["preview_url"] = _normalize_path(preview) + + renamed = tmp_path / "renamed" + old_abs = _normalize_path(nested) + new_abs = _normalize_path(renamed) + os.rename(nested, renamed) + + changed = await scanner.rename_known_folder( + "nested", "renamed", previous_path=old_abs, new_path=new_abs + ) + + assert changed is True + assert "renamed" in cache.all_folders + assert "nested" not in cache.all_folders + assert "renamed" in cache.folders + assert "nested" not in cache.folders + assert entry["folder"] == "renamed" + assert entry["file_path"] == _normalize_path(renamed / "two.txt") + assert entry["preview_url"] == _normalize_path(renamed / "two.preview.png") + assert scanner._hash_index.get_path("hash-two") == _normalize_path( + renamed / "two.txt" + ) + + # The sidecar travelled with the directory and was re-pointed in place + payload = json.loads( + (renamed / "two.metadata.json").read_text(encoding="utf-8") + ) + assert payload["file_path"] == _normalize_path(renamed / "two.txt") + assert payload["preview_url"] == _normalize_path(renamed / "two.preview.png") + + +@pytest.mark.asyncio +async def test_rename_known_folder_handles_nested_targets(tmp_path: Path): + (tmp_path / "a" / "b" / "c").mkdir(parents=True) + model = tmp_path / "a" / "b" / "c" / "m.txt" + model.write_text("m", encoding="utf-8") + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + cache = await scanner.get_cached_data() + + old_abs = _normalize_path(tmp_path / "a" / "b") + new_abs = _normalize_path(tmp_path / "a" / "z") + os.rename(tmp_path / "a" / "b", tmp_path / "a" / "z") + + await scanner.rename_known_folder( + "a/b", "a/z", previous_path=old_abs, new_path=new_abs + ) + + assert "a/b" not in cache.all_folders + assert "a/b/c" not in cache.all_folders + assert "a/z" in cache.all_folders + assert "a/z/c" in cache.all_folders + # The parent is an untouched directory in its own right + assert "a" in cache.all_folders + + entry = next(item for item in cache.raw_data if item["model_name"] == "m") + assert entry["folder"] == "a/z/c" + assert entry["file_path"] == _normalize_path(tmp_path / "a" / "z" / "c" / "m.txt") + + +@pytest.mark.asyncio +async def test_rename_known_folder_keeps_unrelated_entries(tmp_path: Path): + first, _, _ = _create_files(tmp_path) + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + cache = await scanner.get_cached_data() + + old_abs = _normalize_path(tmp_path / "nested") + new_abs = _normalize_path(tmp_path / "renamed") + os.rename(tmp_path / "nested", tmp_path / "renamed") + + await scanner.rename_known_folder( + "nested", "renamed", previous_path=old_abs, new_path=new_abs + ) + + root_entry = next(item for item in cache.raw_data if item["model_name"] == "one") + assert root_entry["folder"] == "" + assert root_entry["file_path"] == _normalize_path(first) + + +@pytest.mark.asyncio +async def test_rename_known_folder_rekeys_excluded_models(tmp_path: Path): + nested = tmp_path / "nested" + nested.mkdir() + (nested / "one.txt").write_text("one", encoding="utf-8") + (nested / "skip-me.txt").write_text("skip", encoding="utf-8") + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + + assert scanner._excluded_models == [_normalize_path(nested / "skip-me.txt")] + + old_abs = _normalize_path(nested) + new_abs = _normalize_path(tmp_path / "renamed") + os.rename(nested, tmp_path / "renamed") + + await scanner.rename_known_folder( + "nested", "renamed", previous_path=old_abs, new_path=new_abs + ) + + assert scanner._excluded_models == [ + _normalize_path(tmp_path / "renamed" / "skip-me.txt") + ] + + +@pytest.mark.asyncio +async def test_rename_known_folder_ignores_unchanged_or_empty_names(tmp_path: Path): + _create_files(tmp_path) + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + cache = await scanner.get_cached_data() + before = list(cache.all_folders) + + assert ( + await scanner.rename_known_folder( + "nested", + "nested", + previous_path=_normalize_path(tmp_path / "nested"), + new_path=_normalize_path(tmp_path / "nested"), + ) + is False + ) + assert ( + await scanner.rename_known_folder( + "", + "renamed", + previous_path=_normalize_path(tmp_path), + new_path=_normalize_path(tmp_path / "renamed"), + ) + is False + ) + + assert cache.all_folders == before + + @pytest.mark.asyncio async def test_get_all_folders_updated_after_move(tmp_path: Path): first, _, _ = _create_files(tmp_path)