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:
@@ -85,6 +85,10 @@ export function getApiEndpoints(modelType) {
|
||||
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
|
||||
createFolder: `/api/lm/${modelType}/create-folder`,
|
||||
deleteFolder: `/api/lm/${modelType}/delete-folder`,
|
||||
// Drops the sidebar records of a folder that no longer exists on disk:
|
||||
// the only cleanup left for a node whose directory was moved or deleted
|
||||
// outside LoRA Manager.
|
||||
forgetFolder: `/api/lm/${modelType}/forget-folder`,
|
||||
renameFolder: `/api/lm/${modelType}/rename-folder`,
|
||||
// Resolves a tree-relative folder onto the root(s) that hold it: the
|
||||
// unified folder tree merges every root, so a node does not carry its
|
||||
|
||||
@@ -1440,7 +1440,9 @@ export class BaseModelApiClient {
|
||||
* Only model-free folders can be removed; the backend answers with a 409
|
||||
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the
|
||||
* thrown Error (`code`, `manifest`) so callers can explain the refusal
|
||||
* instead of showing a bare message.
|
||||
* instead of showing a bare message. A folder that is already gone from disk
|
||||
* is forgotten instead (`code: 'missing'`, success), which is what lets the
|
||||
* sidebar clear a node whose directory was moved outside LoRA Manager.
|
||||
*
|
||||
* @param {string} folderPath Absolute business path of the folder
|
||||
* @param {{dryRun?: boolean}} [options]
|
||||
@@ -1468,6 +1470,38 @@ export class BaseModelApiClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the records of a folder that no longer exists on disk.
|
||||
*
|
||||
* Deleting such a folder cannot work -- there is nothing left to remove --
|
||||
* so a node whose directory was moved or deleted outside LoRA Manager is
|
||||
* cleared with this instead. The backend refuses while any root still holds
|
||||
* the directory (`code: 'exists'`), because then it is a delete.
|
||||
*
|
||||
* @param {string} folder - Library-relative folder path (e.g. "Krea2/Women")
|
||||
* @returns {Promise<Object>} `{success: true, folder}` on success
|
||||
*/
|
||||
async forgetFolder(folder) {
|
||||
const response = await fetch(this.apiConfig.endpoints.forgetFolder, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ folder })
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || result.success === false) {
|
||||
const error = new Error(result.error || 'Failed to remove folder from the sidebar');
|
||||
error.code = result.code || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Rename a folder inside the library roots.
|
||||
*
|
||||
|
||||
@@ -1734,6 +1734,8 @@ export class SidebarManager {
|
||||
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||
} else if (error?.code === 'missing') {
|
||||
showToast('sidebar.deleteFolderResult.missing', {}, 'warning');
|
||||
} else if (error?.code === 'exists') {
|
||||
showToast('sidebar.deleteFolderResult.exists', {}, 'warning');
|
||||
} else if (error?.code === 'unresolved') {
|
||||
showToast('sidebar.folderResult.unresolved', {}, 'error');
|
||||
} else {
|
||||
@@ -1806,6 +1808,8 @@ export class SidebarManager {
|
||||
);
|
||||
} else {
|
||||
this._pendingDeleteFolderPath = null;
|
||||
confirmBtn.style.display = 'none';
|
||||
confirmBtn.disabled = true;
|
||||
if (state === 'busy') {
|
||||
title.textContent = translate(
|
||||
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
|
||||
@@ -1820,7 +1824,16 @@ export class SidebarManager {
|
||||
);
|
||||
message.textContent = translate(
|
||||
'sidebar.deleteFolderModal.missingMessage', {},
|
||||
'This folder no longer exists on disk. Refresh the sidebar and try again.'
|
||||
'This folder no longer exists on disk. Its entries can still be removed from the sidebar.'
|
||||
);
|
||||
// There is nothing left to delete, but the records are what keeps
|
||||
// the ghost node in the tree: offer to drop them (issue #1145).
|
||||
// The confirm re-resolves the node, finds no directory and takes
|
||||
// the forget path.
|
||||
confirmBtn.style.display = '';
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = translate(
|
||||
'sidebar.deleteFolderModal.removeFromList', {}, 'Remove from list'
|
||||
);
|
||||
} else if (state === 'symlink') {
|
||||
title.textContent = translate(
|
||||
@@ -1849,8 +1862,6 @@ export class SidebarManager {
|
||||
|| 'This folder still contains models. Delete or move them first.'
|
||||
);
|
||||
}
|
||||
confirmBtn.style.display = 'none';
|
||||
confirmBtn.disabled = true;
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
@@ -1943,10 +1954,14 @@ export class SidebarManager {
|
||||
);
|
||||
if (!selected) {
|
||||
const empty = (resolution?.candidates || []).length === 0;
|
||||
if (empty) {
|
||||
// No root holds the directory any more: there is nothing
|
||||
// to delete, so drop the records that keep the node in
|
||||
// the tree (issue #1145).
|
||||
return this._forgetFolder(relativePath);
|
||||
}
|
||||
showToast(
|
||||
empty
|
||||
? 'sidebar.deleteFolderResult.missing'
|
||||
: 'sidebar.deleteFolderResult.symlink',
|
||||
'sidebar.deleteFolderResult.symlink',
|
||||
{},
|
||||
'warning'
|
||||
);
|
||||
@@ -1967,7 +1982,15 @@ export class SidebarManager {
|
||||
await this.refresh();
|
||||
|
||||
const name = result.folder || relativePath;
|
||||
if (result.restorable) {
|
||||
if (result.code === 'missing') {
|
||||
// The directory was already gone when the delete ran (a race with
|
||||
// the file manager, or a row in the multi-root list): only its
|
||||
// records were dropped, so nothing was deleted and there is
|
||||
// nothing to undo.
|
||||
showToast(
|
||||
'sidebar.deleteFolderResult.forgotten', { name }, 'success'
|
||||
);
|
||||
} else if (result.restorable) {
|
||||
// A truly empty folder is reproducible one-for-one, so offer
|
||||
// the same 20s undo affordance the model delete flow uses.
|
||||
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
|
||||
@@ -1992,6 +2015,41 @@ export class SidebarManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a folder node whose directory is gone from every root (issue #1145).
|
||||
*
|
||||
* A folder moved or deleted with a file manager leaves the sidebar holding a
|
||||
* node that no delete can clear, because there is nothing left to remove. The
|
||||
* delete affordance falls back to this instead of refusing: the backend drops
|
||||
* the folder record and the cached models under it, and answers `exists` if
|
||||
* the directory turned out to be back on disk (then it is a delete again).
|
||||
*/
|
||||
async _forgetFolder(relativePath) {
|
||||
if (
|
||||
!this._supportsFolderManagement()
|
||||
|| typeof this.apiClient.forgetFolder !== 'function'
|
||||
) {
|
||||
showToast('sidebar.deleteFolderResult.unsupported', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.apiClient.forgetFolder(relativePath);
|
||||
this._forgetRemovedFolder(relativePath);
|
||||
await this.refresh();
|
||||
showToast(
|
||||
'sidebar.deleteFolderResult.forgotten',
|
||||
{ name: result.folder || relativePath },
|
||||
'success'
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Error forgetting folder:', error);
|
||||
this._reportDeleteFolderFailure(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate removed directories through the create-folder API.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user