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
+63 -3
View File
@@ -400,14 +400,74 @@ async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
@pytest.mark.asyncio
async def test_delete_folder_reports_missing_directory(tmp_path: Path):
async def test_delete_folder_forgets_a_directory_that_is_already_gone(tmp_path: Path):
"""There is nothing to delete, but the sidebar node can still be cleared.
A folder moved with a file manager leaves its records behind; answering
"Folder no longer exists" left the ghost with no way out of the UI (#1145).
"""
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
gone = tmp_path / "gone"
result = await service.delete_folder(str(gone))
assert result["success"] is True
assert result["code"] == "missing"
assert result["already_missing"] is True
assert result["folder"] == "gone"
assert scanner.removed_folders == ["gone"]
assert scanner.removed_folder_paths == [str(gone)]
@pytest.mark.asyncio
async def test_delete_folder_dry_run_still_reports_a_missing_directory(tmp_path: Path):
"""The probe stays a pure read — it is what makes the dialog offer the removal."""
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path / "gone"))
result = await service.delete_folder(str(tmp_path / "gone"), dry_run=True)
assert result["success"] is False
assert "no longer exists" in result["error"]
assert result["code"] == "missing"
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_forget_folder_drops_the_records_of_a_gone_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.forget_folder("Krea2/Women")
assert result["success"] is True
assert result["folder"] == "Krea2/Women"
# No absolute path: the same relative folder may exist under several roots,
# and the scanner keeps an entry another root still holds.
assert scanner.removed_folders == ["Krea2/Women"]
assert scanner.removed_folder_paths == [None]
@pytest.mark.asyncio
async def test_forget_folder_refuses_while_the_directory_still_exists(tmp_path: Path):
"""Then the caller wants a delete, not a cleanup."""
(tmp_path / "Krea2" / "Women").mkdir(parents=True)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.forget_folder("Krea2/Women")
assert result["success"] is False
assert result["code"] == "exists"
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_forget_folder_requires_a_folder(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
assert (await service.forget_folder(""))["success"] is False
assert (await service.forget_folder("../outside"))["success"] is False
@pytest.mark.asyncio