mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-12 04:22:13 -03:00
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:
@@ -70,6 +70,7 @@ function createApiClient(overrides = {}) {
|
||||
dir_count: 0,
|
||||
restorable: true,
|
||||
}),
|
||||
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'empty' }),
|
||||
renameFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
renamed: true,
|
||||
@@ -1294,7 +1295,7 @@ describe('SidebarManager folder deletion', () => {
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a stale tree node as missing instead of fabricating a path', async () => {
|
||||
it('offers to clear a stale tree node instead of fabricating a path', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true, folder: 'removed', candidates: [],
|
||||
@@ -1307,9 +1308,85 @@ describe('SidebarManager folder deletion', () => {
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('missing');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
// Nothing to delete, so the delete probe never runs and no path is guessed...
|
||||
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
// ...but the records are all that keeps the node alive, so they can be
|
||||
// dropped from here (issue #1145).
|
||||
expect(confirmBtn().style.display).toBe('');
|
||||
expect(confirmBtn().disabled).toBe(false);
|
||||
expect(confirmBtn().textContent).toBe('Remove from list');
|
||||
});
|
||||
|
||||
it('clears the node through the forget endpoint when no root holds it', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true, folder: 'removed', candidates: [],
|
||||
}),
|
||||
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'removed' }),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.showDeleteFolderModal('removed');
|
||||
const cleared = await manager.handleDeleteFolderConfirm();
|
||||
|
||||
expect(cleared).toBe(true);
|
||||
expect(apiClient.forgetFolder).toHaveBeenCalledWith('removed');
|
||||
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.forgotten',
|
||||
{ name: 'removed' },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a delete that found the folder gone as cleared, not deleted', async () => {
|
||||
// The probe can pass and the directory still be gone by the time the real
|
||||
// delete runs (a file manager move in between, or another row of the
|
||||
// multi-root list): the records are dropped and nothing was deleted.
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
code: 'missing',
|
||||
already_missing: true,
|
||||
folder: 'empty',
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const deleted = await manager._deleteFolder('empty', '/models/loras/empty');
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.forgotten',
|
||||
{ name: 'empty' },
|
||||
'success'
|
||||
);
|
||||
expect(showActionToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when the folder turned out to be back on disk', async () => {
|
||||
const apiClient = createApiClient({
|
||||
resolveFolder: vi.fn().mockResolvedValue({
|
||||
success: true, folder: 'back', candidates: [],
|
||||
}),
|
||||
forgetFolder: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('The folder still exists on disk'), { code: 'exists' })
|
||||
),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cleared = await manager._deleteFolder('back');
|
||||
|
||||
expect(cleared).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.exists', {}, 'warning'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a node whose every copy is a symbolic link', async () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user