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
+68
View File
@@ -23,6 +23,10 @@ class FakeMoveService:
self.received_dry_run = dry_run
return self._result
async def forget_folder(self, folder):
self.received_folder = folder
return self._result
async def rename_folder(self, folder_path, new_name):
self.received_path = folder_path
self.received_new_name = new_name
@@ -34,13 +38,26 @@ class FakeMoveService:
class FakeRequest:
method = "POST"
def __init__(self, payload):
self._payload = payload
# A real aiohttp request always carries a query string too.
self.query = {}
async def json(self):
return self._payload
class FakeGetRequest:
"""GET stand-in carrying only a query string, as the extension sends it."""
method = "GET"
def __init__(self, query):
self.query = query
class FakeQueryRequest:
"""Request stand-in exposing only the query string."""
@@ -394,3 +411,54 @@ async def test_rename_folder_invalid_json_body():
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_forget_folder_success():
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
assert response.status == 200
assert json.loads(response.text)["folder"] == "Krea2/Women"
assert service.received_folder == "Krea2/Women"
@pytest.mark.asyncio
async def test_forget_folder_accepts_get_with_query_params():
"""Forgetting is a state change, but the extension is GET-only by convention."""
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
response = await handler.forget_folder(
FakeGetRequest({"folder": "Krea2/Women"})
)
assert response.status == 200
assert service.received_folder == "Krea2/Women"
@pytest.mark.asyncio
async def test_forget_folder_requires_a_folder():
handler, service = _make_handler({"success": True})
response = await handler.forget_folder(FakeRequest({}))
assert response.status == 400
assert json.loads(response.text)["success"] is False
assert service.received_folder is None
@pytest.mark.asyncio
async def test_forget_folder_existing_directory_maps_to_409():
"""The tree the client rendered is stale: the folder must be deleted."""
handler, _service = _make_handler(
{
"success": False,
"code": "exists",
"error": "The folder still exists on disk",
}
)
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
assert response.status == 409