mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-10 11:32:13 -03:00
fix(sidecars): report over-long migration paths and fix root relocation (#1142)
- Surface Windows WinError 206 / ENAMETOOLONG during sidecar migration as an actionable per-model error instead of a bare OSError - Offer root relocation based on the resolved sidecar root, so moving off (or back to) the default storage directory relocates existing sidecars - Hint in the migration confirm dialog when the destination is the default location, so a custom folder can be set before migrating
This commit is contained in:
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
|
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
|
||||||
"confirmButton": "Jetzt verschieben",
|
"confirmButton": "Jetzt verschieben",
|
||||||
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
|
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
|
||||||
"destination": "Ziel: {path}"
|
"destination": "Ziel: {path}",
|
||||||
|
"defaultLocationHint": "Es wird der Standard-Speicherort verwendet. Um einen eigenen Ordner zu nutzen, brechen Sie ab, setzen Sie oben den Speicherpfad und klicken Sie dann auf „Sidecar-Dateien jetzt verschieben“."
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "Sidecar-Migrationsübersicht",
|
"title": "Sidecar-Migrationsübersicht",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "Move sidecars back next to model files?",
|
"titleToAlongside": "Move sidecars back next to model files?",
|
||||||
"confirmButton": "Migrate Now",
|
"confirmButton": "Migrate Now",
|
||||||
"titleRelocateRoot": "Move sidecars to the new storage directory?",
|
"titleRelocateRoot": "Move sidecars to the new storage directory?",
|
||||||
"destination": "Destination: {path}"
|
"destination": "Destination: {path}",
|
||||||
|
"defaultLocationHint": "This uses the default location. To use a custom folder, cancel, set the storage path above, then click \"Migrate Sidecars Now\"."
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "Sidecar Migration Summary",
|
"title": "Sidecar Migration Summary",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
|
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
|
||||||
"confirmButton": "Migrar ahora",
|
"confirmButton": "Migrar ahora",
|
||||||
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
|
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
|
||||||
"destination": "Destino: {path}"
|
"destination": "Destino: {path}",
|
||||||
|
"defaultLocationHint": "Se usará la ubicación predeterminada. Para usar una carpeta personalizada, cancela, define la ruta de almacenamiento arriba y luego haz clic en «Migrar archivos sidecar ahora»."
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "Resumen de la migración de archivos sidecar",
|
"title": "Resumen de la migración de archivos sidecar",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
|
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
|
||||||
"confirmButton": "Migrer maintenant",
|
"confirmButton": "Migrer maintenant",
|
||||||
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
|
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
|
||||||
"destination": "Destination : {path}"
|
"destination": "Destination : {path}",
|
||||||
|
"defaultLocationHint": "L’emplacement par défaut sera utilisé. Pour utiliser un dossier personnalisé, annulez, définissez le chemin de stockage ci-dessus, puis cliquez sur « Migrer les fichiers sidecar maintenant »."
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "Résumé de la migration des fichiers sidecar",
|
"title": "Résumé de la migration des fichiers sidecar",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
|
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
|
||||||
"confirmButton": "העבר כעת",
|
"confirmButton": "העבר כעת",
|
||||||
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
|
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
|
||||||
"destination": "יעד: {path}"
|
"destination": "יעד: {path}",
|
||||||
|
"defaultLocationHint": "נעשה שימוש במיקום ברירת המחדל. כדי להשתמש בתיקייה מותאמת אישית, בטל, הגדר את נתיב האחסון למעלה ולאחר מכן לחץ על «העבר קובצי לוואי כעת»."
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "סיכום העברת קובצי לוואי",
|
"title": "סיכום העברת קובצי לוואי",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
|
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
|
||||||
"confirmButton": "今すぐ移動",
|
"confirmButton": "今すぐ移動",
|
||||||
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
|
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
|
||||||
"destination": "移動先:{path}"
|
"destination": "移動先:{path}",
|
||||||
|
"defaultLocationHint": "デフォルトの保存場所が使用されます。カスタムフォルダを使用するには、キャンセルして上の保存先パスを設定してから、「今すぐサイドカーファイルを移動」をクリックしてください。"
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "サイドカーファイル移動の概要",
|
"title": "サイドカーファイル移動の概要",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
|
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
|
||||||
"confirmButton": "지금 이동",
|
"confirmButton": "지금 이동",
|
||||||
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
|
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
|
||||||
"destination": "대상 위치: {path}"
|
"destination": "대상 위치: {path}",
|
||||||
|
"defaultLocationHint": "기본 저장 위치가 사용됩니다. 사용자 지정 폴더를 사용하려면 취소하고 위에서 저장 경로를 설정한 다음 '지금 사이드카 파일 이동'을 클릭하세요."
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "사이드카 파일 이동 요약",
|
"title": "사이드카 파일 이동 요약",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
|
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
|
||||||
"confirmButton": "Перенести сейчас",
|
"confirmButton": "Перенести сейчас",
|
||||||
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
|
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
|
||||||
"destination": "Назначение: {path}"
|
"destination": "Назначение: {path}",
|
||||||
|
"defaultLocationHint": "Будет использовано расположение по умолчанию. Чтобы использовать свою папку, отмените, укажите путь хранилища выше, затем нажмите «Перенести sidecar-файлы сейчас»."
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "Сводка переноса sidecar-файлов",
|
"title": "Сводка переноса sidecar-файлов",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
|
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
|
||||||
"confirmButton": "立即迁移",
|
"confirmButton": "立即迁移",
|
||||||
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
|
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
|
||||||
"destination": "目标位置:{path}"
|
"destination": "目标位置:{path}",
|
||||||
|
"defaultLocationHint": "将使用默认存储位置。如需使用自定义文件夹,请取消,在上方设置存储路径,然后点击“立即迁移附属文件”。"
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "附属文件迁移摘要",
|
"title": "附属文件迁移摘要",
|
||||||
|
|||||||
+2
-1
@@ -1749,7 +1749,8 @@
|
|||||||
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
|
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
|
||||||
"confirmButton": "立即遷移",
|
"confirmButton": "立即遷移",
|
||||||
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
|
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
|
||||||
"destination": "目標位置:{path}"
|
"destination": "目標位置:{path}",
|
||||||
|
"defaultLocationHint": "將使用預設儲存位置。如需使用自訂資料夾,請取消,在上方設定儲存路徑,然後點擊「立即遷移附屬檔案」。"
|
||||||
},
|
},
|
||||||
"sidecarMigrationResult": {
|
"sidecarMigrationResult": {
|
||||||
"title": "附屬檔案遷移摘要",
|
"title": "附屬檔案遷移摘要",
|
||||||
|
|||||||
@@ -645,6 +645,23 @@ class SidecarMigrationUseCase:
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise_path_error(exc: OSError, dst: str) -> None:
|
||||||
|
"""Re-raise path-length OS errors with an actionable message.
|
||||||
|
|
||||||
|
Windows rejects paths over MAX_PATH (260 chars) with WinError 206
|
||||||
|
(ERROR_FILENAME_EXCED_RANGE); Linux reports ENAMETOOLONG. A bare
|
||||||
|
OSError gives no hint that the sidecar root is simply too deep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if getattr(exc, "winerror", None) == 206 or exc.errno == errno.ENAMETOOLONG:
|
||||||
|
raise OSError(
|
||||||
|
f"Destination path is too long for the OS ({len(dst)} chars; "
|
||||||
|
f"Windows limit is 260): {dst}. Choose a shallower sidecar "
|
||||||
|
f"storage directory or shorten the file name."
|
||||||
|
) from exc
|
||||||
|
raise exc
|
||||||
|
|
||||||
def _transfer(self, src: str, dst: str, result: Dict[str, Any]) -> bool:
|
def _transfer(self, src: str, dst: str, result: Dict[str, Any]) -> bool:
|
||||||
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
|
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
|
||||||
|
|
||||||
@@ -667,7 +684,10 @@ class SidecarMigrationUseCase:
|
|||||||
)
|
)
|
||||||
os.remove(src)
|
os.remove(src)
|
||||||
return False
|
return False
|
||||||
self._move_file(src, dst)
|
try:
|
||||||
|
self._move_file(src, dst)
|
||||||
|
except OSError as exc:
|
||||||
|
self._raise_path_error(exc, dst)
|
||||||
result["moved"] += 1
|
result["moved"] += 1
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -3419,8 +3419,13 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
// Baseline used to detect a mode change in handleSidecarStorageModeChange
|
// Baseline used to detect a mode change in handleSidecarStorageModeChange
|
||||||
this._loadedSidecarStorageMode = currentMode;
|
this._loadedSidecarStorageMode = currentMode;
|
||||||
// Baseline used to detect a root change in handleSidecarStoragePathChange
|
// Baseline of the raw path input, refreshed in handleSidecarStoragePathChange
|
||||||
this._loadedSidecarStoragePath = state.global.settings.sidecar_storage_path || '';
|
this._loadedSidecarStoragePath = state.global.settings.sidecar_storage_path || '';
|
||||||
|
// Baseline used to detect a root change in handleSidecarStoragePathChange.
|
||||||
|
// Must be the RESOLVED root, not the raw setting: with no custom path the
|
||||||
|
// setting is empty while sidecars live in the default root, and that
|
||||||
|
// default->custom transition is exactly the relocation we must offer.
|
||||||
|
this._loadedSidecarStorageRoot = state.global.settings.sidecar_storage_root || '';
|
||||||
|
|
||||||
const pathInput = document.getElementById('sidecarStoragePath');
|
const pathInput = document.getElementById('sidecarStoragePath');
|
||||||
if (pathInput) {
|
if (pathInput) {
|
||||||
@@ -3527,26 +3532,30 @@ export class SettingsManager {
|
|||||||
|
|
||||||
// Path change while centralized storage is active: the assets under the
|
// Path change while centralized storage is active: the assets under the
|
||||||
// previous root do not move by themselves, so offer a root relocation.
|
// previous root do not move by themselves, so offer a root relocation.
|
||||||
|
// Compare RESOLVED roots (the raw setting is empty for the default root,
|
||||||
|
// which still holds sidecars and must be relocatable).
|
||||||
async handleSidecarStoragePathChange() {
|
async handleSidecarStoragePathChange() {
|
||||||
const pathInput = document.getElementById('sidecarStoragePath');
|
const pathInput = document.getElementById('sidecarStoragePath');
|
||||||
if (!pathInput) return;
|
if (!pathInput) return;
|
||||||
|
|
||||||
const previousPath = this._loadedSidecarStoragePath || '';
|
const previousRoot = this._loadedSidecarStorageRoot || '';
|
||||||
|
|
||||||
await this.saveInputSetting('sidecarStoragePath', 'sidecar_storage_path');
|
await this.saveInputSetting('sidecarStoragePath', 'sidecar_storage_path');
|
||||||
|
|
||||||
const newPath = pathInput.value.trim();
|
this._loadedSidecarStoragePath = pathInput.value.trim();
|
||||||
this._loadedSidecarStoragePath = newPath;
|
|
||||||
|
|
||||||
// The resolved root is server-side; refresh before any relocate
|
// The resolved root is server-side; refresh before any relocate
|
||||||
// confirm so the dialog can name the real destination.
|
// confirm so the dialog can name the real destination.
|
||||||
await this.refreshSidecarStorageInfo();
|
await this.refreshSidecarStorageInfo();
|
||||||
|
|
||||||
|
const newRoot = state.global.settings.sidecar_storage_root || '';
|
||||||
|
this._loadedSidecarStorageRoot = newRoot;
|
||||||
|
|
||||||
const centralized = state.global.settings.sidecar_storage_mode === 'centralized';
|
const centralized = state.global.settings.sidecar_storage_mode === 'centralized';
|
||||||
if (centralized && previousPath && previousPath !== newPath) {
|
if (centralized && previousRoot && previousRoot !== newRoot) {
|
||||||
const confirmed = await this.confirmSidecarMigration('relocate_root');
|
const confirmed = await this.confirmSidecarMigration('relocate_root');
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await this.migrateSidecars('relocate_root', { old_root: previousPath });
|
await this.migrateSidecars('relocate_root', { old_root: previousRoot });
|
||||||
} else {
|
} else {
|
||||||
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||||
}
|
}
|
||||||
@@ -3606,6 +3615,22 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrating into the default location: let users pick a custom folder
|
||||||
|
// first instead of migrating now and relocating afterwards.
|
||||||
|
const hintElement = modalElement.querySelector('[data-role="default-location-hint"]');
|
||||||
|
if (hintElement) {
|
||||||
|
if (isToCentralized && state.global.settings.sidecar_storage_root_is_default) {
|
||||||
|
hintElement.textContent = translate(
|
||||||
|
'modals.sidecarMigrationConfirm.defaultLocationHint',
|
||||||
|
{},
|
||||||
|
'This uses the default location. To use a custom folder, cancel, set the storage path above, then click "Migrate Sidecars Now".'
|
||||||
|
);
|
||||||
|
hintElement.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
hintElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const confirmButton = modalElement.querySelector('[data-action="confirm-sidecar-migration"]');
|
const confirmButton = modalElement.querySelector('[data-action="confirm-sidecar-migration"]');
|
||||||
const cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
|
const cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
|
||||||
if (!confirmButton || !cancelButton) {
|
if (!confirmButton || !cancelButton) {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
<h2 data-role="title"></h2>
|
<h2 data-role="title"></h2>
|
||||||
<p class="delete-message" data-role="message"></p>
|
<p class="delete-message" data-role="message"></p>
|
||||||
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
|
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
|
||||||
|
<p class="delete-message sidecar-migration-hint" data-role="default-location-hint" style="display: none;"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="cancel-btn" data-action="cancel-sidecar-migration">{{ t('common.actions.cancel') }}</button>
|
<button class="cancel-btn" data-action="cancel-sidecar-migration">{{ t('common.actions.cancel') }}</button>
|
||||||
<button class="primary-btn" data-action="confirm-sidecar-migration"></button>
|
<button class="primary-btn" data-action="confirm-sidecar-migration"></button>
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ const appendMigrationModal = () => {
|
|||||||
<h2 data-role="title"></h2>
|
<h2 data-role="title"></h2>
|
||||||
<p data-role="message"></p>
|
<p data-role="message"></p>
|
||||||
<p data-role="destination" style="display:none"></p>
|
<p data-role="destination" style="display:none"></p>
|
||||||
|
<p data-role="default-location-hint" style="display:none"></p>
|
||||||
<button data-action="confirm-sidecar-migration"></button>
|
<button data-action="confirm-sidecar-migration"></button>
|
||||||
<button data-action="cancel-sidecar-migration"></button>`;
|
<button data-action="cancel-sidecar-migration"></button>`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
@@ -130,6 +131,26 @@ const mockFetchOk = (payload = { success: true }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch mock where the GET /api/lm/settings refresh reports ``resolvedRoot``
|
||||||
|
// as the new resolved sidecar root; every other call succeeds generically.
|
||||||
|
const mockFetchWithResolvedRoot = (resolvedRoot) => {
|
||||||
|
global.fetch = vi.fn((url, options) => {
|
||||||
|
if (url === '/api/lm/settings' && !options) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
settings: { sidecar_storage_root: resolvedRoot },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ success: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -255,6 +276,53 @@ describe('SettingsManager sidecar storage', () => {
|
|||||||
await changePromise;
|
await changePromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hints at setting a custom folder first when migrating to the default location', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const { select } = appendSidecarControls();
|
||||||
|
const modal = appendMigrationModal();
|
||||||
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'alongside',
|
||||||
|
sidecar_storage_root: '/settings/sidecars',
|
||||||
|
sidecar_storage_root_is_default: true,
|
||||||
|
};
|
||||||
|
manager._loadedSidecarStorageMode = 'alongside';
|
||||||
|
select.value = 'centralized';
|
||||||
|
mockFetchOk();
|
||||||
|
|
||||||
|
const changePromise = manager.handleSidecarStorageModeChange();
|
||||||
|
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||||
|
|
||||||
|
const hint = modal.querySelector('[data-role="default-location-hint"]');
|
||||||
|
expect(hint.style.display).toBe('block');
|
||||||
|
expect(hint.textContent).toContain('default location');
|
||||||
|
|
||||||
|
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
|
||||||
|
await changePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the default-location hint for custom roots and other directions', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const { select } = appendSidecarControls();
|
||||||
|
const modal = appendMigrationModal();
|
||||||
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'alongside',
|
||||||
|
sidecar_storage_root: '/data/sidecars',
|
||||||
|
sidecar_storage_root_is_default: false,
|
||||||
|
};
|
||||||
|
manager._loadedSidecarStorageMode = 'alongside';
|
||||||
|
select.value = 'centralized';
|
||||||
|
mockFetchOk();
|
||||||
|
|
||||||
|
const changePromise = manager.handleSidecarStorageModeChange();
|
||||||
|
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||||
|
|
||||||
|
const hint = modal.querySelector('[data-role="default-location-hint"]');
|
||||||
|
expect(hint.style.display).toBe('none');
|
||||||
|
|
||||||
|
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
|
||||||
|
await changePromise;
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a deferred notice and skips migration when the user cancels', async () => { const manager = createManager();
|
it('shows a deferred notice and skips migration when the user cancels', async () => { const manager = createManager();
|
||||||
const { select } = appendSidecarControls();
|
const { select } = appendSidecarControls();
|
||||||
const modal = appendMigrationModal();
|
const modal = appendMigrationModal();
|
||||||
@@ -311,14 +379,20 @@ describe('SettingsManager sidecar storage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleSidecarStoragePathChange', () => { it('offers root relocation when the path changes in centralized mode', async () => {
|
describe('handleSidecarStoragePathChange', () => {
|
||||||
|
it('offers root relocation when the path changes in centralized mode', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
const { pathInput } = appendSidecarControls();
|
const { pathInput } = appendSidecarControls();
|
||||||
const modal = appendMigrationModal();
|
const modal = appendMigrationModal();
|
||||||
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'centralized',
|
||||||
|
sidecar_storage_path: '/old/root',
|
||||||
|
sidecar_storage_root: '/old/root',
|
||||||
|
};
|
||||||
manager._loadedSidecarStoragePath = '/old/root';
|
manager._loadedSidecarStoragePath = '/old/root';
|
||||||
|
manager._loadedSidecarStorageRoot = '/old/root';
|
||||||
pathInput.value = '/new/root';
|
pathInput.value = '/new/root';
|
||||||
mockFetchOk();
|
mockFetchWithResolvedRoot('/new/root');
|
||||||
|
|
||||||
const changePromise = manager.handleSidecarStoragePathChange();
|
const changePromise = manager.handleSidecarStoragePathChange();
|
||||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||||
@@ -329,16 +403,93 @@ describe('SettingsManager sidecar storage', () => {
|
|||||||
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/old/root' }),
|
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/old/root' }),
|
||||||
}));
|
}));
|
||||||
expect(manager._loadedSidecarStoragePath).toBe('/new/root');
|
expect(manager._loadedSidecarStoragePath).toBe('/new/root');
|
||||||
|
expect(manager._loadedSidecarStorageRoot).toBe('/new/root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers relocation from the default root when no custom path was set (#1142)', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const { pathInput } = appendSidecarControls();
|
||||||
|
const modal = appendMigrationModal();
|
||||||
|
// No custom path: the raw setting is empty, but sidecars live in
|
||||||
|
// the resolved default root.
|
||||||
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'centralized',
|
||||||
|
sidecar_storage_path: '',
|
||||||
|
sidecar_storage_root: '/settings/sidecars',
|
||||||
|
};
|
||||||
|
manager._loadedSidecarStoragePath = '';
|
||||||
|
manager._loadedSidecarStorageRoot = '/settings/sidecars';
|
||||||
|
pathInput.value = '/custom/dir';
|
||||||
|
mockFetchWithResolvedRoot('/custom/dir');
|
||||||
|
|
||||||
|
const changePromise = manager.handleSidecarStoragePathChange();
|
||||||
|
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||||
|
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
|
||||||
|
await changePromise;
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
|
||||||
|
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/settings/sidecars' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers relocation back to the default root when the path is cleared', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const { pathInput } = appendSidecarControls();
|
||||||
|
const modal = appendMigrationModal();
|
||||||
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'centralized',
|
||||||
|
sidecar_storage_path: '/custom/dir',
|
||||||
|
sidecar_storage_root: '/custom/dir',
|
||||||
|
};
|
||||||
|
manager._loadedSidecarStoragePath = '/custom/dir';
|
||||||
|
manager._loadedSidecarStorageRoot = '/custom/dir';
|
||||||
|
pathInput.value = '';
|
||||||
|
mockFetchWithResolvedRoot('/settings/sidecars');
|
||||||
|
|
||||||
|
const changePromise = manager.handleSidecarStoragePathChange();
|
||||||
|
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||||
|
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
|
||||||
|
await changePromise;
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
|
||||||
|
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/custom/dir' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prompt when the resolved root is unchanged', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const { pathInput } = appendSidecarControls();
|
||||||
|
appendMigrationModal();
|
||||||
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'centralized',
|
||||||
|
sidecar_storage_path: '/old/root',
|
||||||
|
sidecar_storage_root: '/old/root',
|
||||||
|
};
|
||||||
|
manager._loadedSidecarStoragePath = '/old/root';
|
||||||
|
manager._loadedSidecarStorageRoot = '/old/root';
|
||||||
|
// Whitespace-only edit: saveInputSetting no-ops, root stays put.
|
||||||
|
pathInput.value = '/old/root';
|
||||||
|
mockFetchWithResolvedRoot('/old/root');
|
||||||
|
|
||||||
|
await manager.handleSidecarStoragePathChange();
|
||||||
|
|
||||||
|
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
|
||||||
|
expect(migrateCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not prompt when the path changes in alongside mode', async () => {
|
it('does not prompt when the path changes in alongside mode', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
const { pathInput } = appendSidecarControls();
|
const { pathInput } = appendSidecarControls();
|
||||||
appendMigrationModal();
|
appendMigrationModal();
|
||||||
state.global.settings = { sidecar_storage_mode: 'alongside', sidecar_storage_path: '/old/root' };
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'alongside',
|
||||||
|
sidecar_storage_path: '/old/root',
|
||||||
|
sidecar_storage_root: '/old/root',
|
||||||
|
};
|
||||||
manager._loadedSidecarStoragePath = '/old/root';
|
manager._loadedSidecarStoragePath = '/old/root';
|
||||||
|
manager._loadedSidecarStorageRoot = '/old/root';
|
||||||
pathInput.value = '/new/root';
|
pathInput.value = '/new/root';
|
||||||
mockFetchOk();
|
mockFetchWithResolvedRoot('/new/root');
|
||||||
|
|
||||||
await manager.handleSidecarStoragePathChange();
|
await manager.handleSidecarStoragePathChange();
|
||||||
|
|
||||||
@@ -350,10 +501,15 @@ describe('SettingsManager sidecar storage', () => {
|
|||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
const { pathInput } = appendSidecarControls();
|
const { pathInput } = appendSidecarControls();
|
||||||
const modal = appendMigrationModal();
|
const modal = appendMigrationModal();
|
||||||
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
|
state.global.settings = {
|
||||||
|
sidecar_storage_mode: 'centralized',
|
||||||
|
sidecar_storage_path: '/old/root',
|
||||||
|
sidecar_storage_root: '/old/root',
|
||||||
|
};
|
||||||
manager._loadedSidecarStoragePath = '/old/root';
|
manager._loadedSidecarStoragePath = '/old/root';
|
||||||
|
manager._loadedSidecarStorageRoot = '/old/root';
|
||||||
pathInput.value = '/new/root';
|
pathInput.value = '/new/root';
|
||||||
mockFetchOk();
|
mockFetchWithResolvedRoot('/new/root');
|
||||||
|
|
||||||
const changePromise = manager.handleSidecarStoragePathChange();
|
const changePromise = manager.handleSidecarStoragePathChange();
|
||||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||||
|
|||||||
@@ -619,3 +619,112 @@ async def test_migrate_covers_excluded_models(
|
|||||||
# The excluded model is not in the cache, so cache reconciliation is a
|
# The excluded model is not in the cache, so cache reconciliation is a
|
||||||
# no-op for it and only the cached entry gets persisted.
|
# no-op for it and only the cached entry gets persisted.
|
||||||
assert use_case._test_scanner.persist_calls == 1
|
assert use_case._test_scanner.persist_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_reports_destination_path_too_long(
|
||||||
|
library_root: Path, sidecar_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""Windows WinError 206 becomes an actionable per-model error (#1142)."""
|
||||||
|
|
||||||
|
_set_mode("centralized")
|
||||||
|
model = _write_model(library_root, "model")
|
||||||
|
sidecar = _write_sidecar(library_root, "model", model, preview_ext=None)
|
||||||
|
|
||||||
|
use_case = _make_use_case([str(model)])
|
||||||
|
|
||||||
|
def failing_move(src: str, dst: str) -> None:
|
||||||
|
exc = OSError("The filename or extension is too long")
|
||||||
|
exc.winerror = 206 # ERROR_FILENAME_EXCED_RANGE
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||||
|
summary = await use_case.migrate_to_centralized(force=True)
|
||||||
|
|
||||||
|
assert summary["success"] is False
|
||||||
|
assert summary["error_count"] == 1
|
||||||
|
assert summary["moved"] == 0
|
||||||
|
message = summary["errors"][0]["error"]
|
||||||
|
assert "too long" in message
|
||||||
|
assert "shallower sidecar storage directory" in message
|
||||||
|
# Failed move leaves the source untouched: no partial migration.
|
||||||
|
assert sidecar.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_reports_enametoolong(
|
||||||
|
library_root: Path, sidecar_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""ENAMETOOLONG gets the same actionable message as WinError 206."""
|
||||||
|
|
||||||
|
import errno as errno_module
|
||||||
|
|
||||||
|
_set_mode("centralized")
|
||||||
|
model = _write_model(library_root, "model")
|
||||||
|
_write_sidecar(library_root, "model", model, preview_ext=None)
|
||||||
|
|
||||||
|
use_case = _make_use_case([str(model)])
|
||||||
|
|
||||||
|
def failing_move(src: str, dst: str) -> None:
|
||||||
|
raise OSError(errno_module.ENAMETOOLONG, "File name too long")
|
||||||
|
|
||||||
|
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||||
|
summary = await use_case.migrate_to_centralized(force=True)
|
||||||
|
|
||||||
|
assert summary["success"] is False
|
||||||
|
assert "too long" in summary["errors"][0]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_keeps_unrelated_oserror_message(
|
||||||
|
library_root: Path, sidecar_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""Non-path-length OSErrors propagate with their original message."""
|
||||||
|
|
||||||
|
import errno as errno_module
|
||||||
|
|
||||||
|
_set_mode("centralized")
|
||||||
|
model = _write_model(library_root, "model")
|
||||||
|
_write_sidecar(library_root, "model", model, preview_ext=None)
|
||||||
|
|
||||||
|
use_case = _make_use_case([str(model)])
|
||||||
|
|
||||||
|
def failing_move(src: str, dst: str) -> None:
|
||||||
|
raise OSError(errno_module.EACCES, "Permission denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||||
|
summary = await use_case.migrate_to_centralized(force=True)
|
||||||
|
|
||||||
|
assert summary["success"] is False
|
||||||
|
message = summary["errors"][0]["error"]
|
||||||
|
assert "Permission denied" in message
|
||||||
|
assert "too long" not in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_root_reports_destination_path_too_long(
|
||||||
|
library_root: Path, sidecar_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""Root relocation surfaces path-length failures per file (#1142)."""
|
||||||
|
|
||||||
|
_set_mode("centralized")
|
||||||
|
old_root = tmp_path / "old_sidecars"
|
||||||
|
old_mirror = old_root / "component" / "sub"
|
||||||
|
old_mirror.mkdir(parents=True)
|
||||||
|
(old_mirror / "model.metadata.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
use_case = _make_use_case([])
|
||||||
|
|
||||||
|
def failing_move(src: str, dst: str) -> None:
|
||||||
|
exc = OSError("The filename or extension is too long")
|
||||||
|
exc.winerror = 206
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||||
|
summary = await use_case.migrate_root(str(old_root), force=True)
|
||||||
|
|
||||||
|
assert summary["success"] is False
|
||||||
|
assert summary["moved"] == 0
|
||||||
|
assert any("too long" in entry["error"] for entry in summary["errors"])
|
||||||
|
# Source file stays in the old tree.
|
||||||
|
assert (old_mirror / "model.metadata.json").exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user