fix(sidebar): let a ghost folder be cleared from the UI (#1145)

Deleting a folder a file manager had already moved away answered "Folder no longer
exists" and stopped there: `remove_known_folder()` only ran after `shutil.rmtree()`
succeeded, so the records keeping the node in the tree could never be dropped, and
the modal's missing state hid its confirm button. The sidebar was a dead end for
exactly the case the folder list cannot self-heal.

* `ModelMoveService.forget_folder()` drops a folder's records (the recorded entry
  plus the cached models under it) and refuses with `code: "exists"` while any root
  still holds the directory, because then the caller wants a delete.
* `delete_folder()` forgets the records instead of failing when the directory is
  already gone. The dry run stays a pure read and keeps answering `missing`, which
  is what makes the dialog offer the removal. `POST`/`GET
  /api/lm/{prefix}/forget-folder` is the endpoint for a node that maps to no
  directory at all (the GET form is for the browser extension, which is GET-only).
* The sidebar falls back to it from the delete flow, offers "Remove from list" in
  the missing state, and reports the folder as removed from the sidebar rather than
  as a delete that never happened. An `exists` conflict says the directory is back
  and should be deleted instead.

Verified: 3811 backend tests, 1543 JS tests, 96 Vue tests, 20 i18n tests.
This commit is contained in:
Will Miao
2026-10-12 09:31:29 +08:00
parent 0508daa0fb
commit 3a718ce2c3
19 changed files with 432 additions and 18 deletions
+34
View File
@@ -2629,6 +2629,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 forget_folder(self, request: web.Request) -> web.Response:
"""Drop the records of a folder that no longer exists on disk (#1145).
The sidebar's delete flow reaches this when the node it holds maps to no
directory any more: there is nothing to delete, so the only cleanup left
is forgetting the folder entry and the cached models under it.
"""
data: Dict[str, Any] = {}
if request.method == "POST":
try:
data = await request.json()
except Exception:
data = {}
folder = str(data.get("folder") or request.query.get("folder") or "").strip()
if not folder:
return web.json_response(
{"success": False, "error": "Folder is required"}, status=400
)
try:
result = await self._move_service.forget_folder(folder)
if result.get("success"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# `exists` is a conflict between the tree the client rendered and the
# on-disk truth: the folder is still there, so it must be deleted.
status = 409 if result.get("code") == "exists" else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error forgetting 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()
@@ -3701,6 +3734,7 @@ class ModelHandlerSet:
"create_folder": self.move.create_folder,
"resolve_folder": self.move.resolve_folder,
"delete_folder": self.move.delete_folder,
"forget_folder": self.move.forget_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,
+4
View File
@@ -42,6 +42,10 @@ 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"),
# Forgetting a folder that is gone from disk is a state change, so it takes
# GET with query params too (the browser extension is GET-only by convention).
RouteDefinition("POST", "/api/lm/{prefix}/forget-folder", "forget_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/forget-folder", "forget_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"),
+48 -3
View File
@@ -589,6 +589,33 @@ class ModelMoveService:
return rel.replace(os.sep, "/")
return ""
async def forget_folder(self, folder: str) -> Dict[str, Any]:
"""Drop the records of a folder that is on disk under no root (#1145).
A directory moved or deleted with a file manager can leave the sidebar
holding a node no delete can clear: the folder is gone, so there is
nothing to remove and every attempt answered "Folder no longer exists".
This drops what remains — the recorded folder entry and the cached model
rows under it — and is refused while any root still holds the directory,
because then the caller wants a delete, not a cleanup.
"""
resolution = self.resolve_folder(folder)
if not resolution.get("success"):
return resolution
normalized = resolution.get("folder") or ""
if not normalized:
return {"success": False, "error": "Folder is required"}
if resolution.get("candidates"):
return {
"success": False,
"code": "exists",
"error": "The folder still exists on disk",
}
await self._forget_folder(normalized)
return {"success": True, "folder": normalized}
def resolve_folder(self, folder: str) -> Dict[str, Any]:
"""Map a library-relative folder onto the directories it stands for.
@@ -685,11 +712,29 @@ class ModelMoveService:
}
if not os.path.isdir(absolute_path):
# `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation.
# directory is gone" apart from a failed operation. There is
# nothing left to delete, but the sidebar can still be holding the
# node: a folder moved or removed with a file manager leaves its
# records behind, and "Folder no longer exists" was a dead end --
# the ghost could not be cleared from the UI at all (issue #1145).
# The real delete therefore forgets those records and reports
# success; the dry run stays a pure read and keeps answering
# `missing`, which is what makes the dialog offer the removal.
if dry_run:
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
relative_folder = self._calculate_relative_folder(absolute_path)
await self._forget_folder(relative_folder, absolute_path)
return {
"success": False,
"success": True,
"code": "missing",
"error": "Folder no longer exists",
"already_missing": True,
"folder": relative_folder,
"folder_path": absolute_path.replace(os.sep, "/"),
}
if self._is_model_root(absolute_path):