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
+4
View File
@@ -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
+35 -1
View File
@@ -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.
*