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:
Will Miao
2026-10-10 14:35:08 +08:00
parent 6d45f9296b
commit 5796a0e2e9
15 changed files with 345 additions and 24 deletions
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
"confirmButton": "Jetzt 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": {
"title": "Sidecar-Migrationsübersicht",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "Move sidecars back next to model files?",
"confirmButton": "Migrate Now",
"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": {
"title": "Sidecar Migration Summary",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
"confirmButton": "Migrar ahora",
"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": {
"title": "Resumen de la migración de archivos sidecar",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
"confirmButton": "Migrer maintenant",
"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": {
"title": "Résumé de la migration des fichiers sidecar",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
"confirmButton": "העבר כעת",
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
"destination": "יעד: {path}"
"destination": "יעד: {path}",
"defaultLocationHint": "נעשה שימוש במיקום ברירת המחדל. כדי להשתמש בתיקייה מותאמת אישית, בטל, הגדר את נתיב האחסון למעלה ולאחר מכן לחץ על «העבר קובצי לוואי כעת»."
},
"sidecarMigrationResult": {
"title": "סיכום העברת קובצי לוואי",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
"confirmButton": "今すぐ移動",
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
"destination": "移動先:{path}"
"destination": "移動先:{path}",
"defaultLocationHint": "デフォルトの保存場所が使用されます。カスタムフォルダを使用するには、キャンセルして上の保存先パスを設定してから、「今すぐサイドカーファイルを移動」をクリックしてください。"
},
"sidecarMigrationResult": {
"title": "サイドカーファイル移動の概要",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
"confirmButton": "지금 이동",
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
"destination": "대상 위치: {path}"
"destination": "대상 위치: {path}",
"defaultLocationHint": "기본 저장 위치가 사용됩니다. 사용자 지정 폴더를 사용하려면 취소하고 위에서 저장 경로를 설정한 다음 '지금 사이드카 파일 이동'을 클릭하세요."
},
"sidecarMigrationResult": {
"title": "사이드카 파일 이동 요약",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
"confirmButton": "Перенести сейчас",
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
"destination": "Назначение: {path}"
"destination": "Назначение: {path}",
"defaultLocationHint": "Будет использовано расположение по умолчанию. Чтобы использовать свою папку, отмените, укажите путь хранилища выше, затем нажмите «Перенести sidecar-файлы сейчас»."
},
"sidecarMigrationResult": {
"title": "Сводка переноса sidecar-файлов",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
"confirmButton": "立即迁移",
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
"destination": "目标位置:{path}"
"destination": "目标位置:{path}",
"defaultLocationHint": "将使用默认存储位置。如需使用自定义文件夹,请取消,在上方设置存储路径,然后点击“立即迁移附属文件”。"
},
"sidecarMigrationResult": {
"title": "附属文件迁移摘要",
+2 -1
View File
@@ -1749,7 +1749,8 @@
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
"confirmButton": "立即遷移",
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
"destination": "目標位置:{path}"
"destination": "目標位置:{path}",
"defaultLocationHint": "將使用預設儲存位置。如需使用自訂資料夾,請取消,在上方設定儲存路徑,然後點擊「立即遷移附屬檔案」。"
},
"sidecarMigrationResult": {
"title": "附屬檔案遷移摘要",
@@ -645,6 +645,23 @@ class SidecarMigrationUseCase:
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:
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
@@ -667,7 +684,10 @@ class SidecarMigrationUseCase:
)
os.remove(src)
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
return True
+31 -6
View File
@@ -3419,8 +3419,13 @@ export class SettingsManager {
}
// Baseline used to detect a mode change in handleSidecarStorageModeChange
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 || '';
// 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');
if (pathInput) {
@@ -3527,26 +3532,30 @@ export class SettingsManager {
// Path change while centralized storage is active: the assets under the
// 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() {
const pathInput = document.getElementById('sidecarStoragePath');
if (!pathInput) return;
const previousPath = this._loadedSidecarStoragePath || '';
const previousRoot = this._loadedSidecarStorageRoot || '';
await this.saveInputSetting('sidecarStoragePath', 'sidecar_storage_path');
const newPath = pathInput.value.trim();
this._loadedSidecarStoragePath = newPath;
this._loadedSidecarStoragePath = pathInput.value.trim();
// The resolved root is server-side; refresh before any relocate
// confirm so the dialog can name the real destination.
await this.refreshSidecarStorageInfo();
const newRoot = state.global.settings.sidecar_storage_root || '';
this._loadedSidecarStorageRoot = newRoot;
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');
if (confirmed) {
await this.migrateSidecars('relocate_root', { old_root: previousPath });
await this.migrateSidecars('relocate_root', { old_root: previousRoot });
} else {
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 cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
if (!confirmButton || !cancelButton) {
@@ -104,6 +104,7 @@
<h2 data-role="title"></h2>
<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-hint" data-role="default-location-hint" style="display: none;"></p>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-sidecar-migration">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" data-action="confirm-sidecar-migration"></button>
@@ -117,6 +117,7 @@ const appendMigrationModal = () => {
<h2 data-role="title"></h2>
<p data-role="message"></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="cancel-sidecar-migration"></button>`;
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(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
@@ -255,6 +276,53 @@ describe('SettingsManager sidecar storage', () => {
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();
const { select } = appendSidecarControls();
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 { pathInput } = appendSidecarControls();
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._loadedSidecarStorageRoot = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
mockFetchWithResolvedRoot('/new/root');
const changePromise = manager.handleSidecarStoragePathChange();
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' }),
}));
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 () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
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._loadedSidecarStorageRoot = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
mockFetchWithResolvedRoot('/new/root');
await manager.handleSidecarStoragePathChange();
@@ -350,10 +501,15 @@ describe('SettingsManager sidecar storage', () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
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._loadedSidecarStorageRoot = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
mockFetchWithResolvedRoot('/new/root');
const changePromise = manager.handleSidecarStoragePathChange();
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
# no-op for it and only the cached entry gets persisted.
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()