feat(other): guide standalone users to settings.json from the no-paths empty state

The standalone empty state showed the folder_paths keys but not where to
put them, and its Open Settings button led to a modal that cannot edit
primary folder paths. Now the page shows the real settings.json path and
an Open Settings Folder button backed by the existing open-location API.

Also stop open_settings_location from claiming success on headless Linux
sessions: with no DISPLAY/WAYLAND_DISPLAY, xdg-open cannot work, so the
handler now returns clipboard mode and the browser copies/shows the path
instead.
This commit is contained in:
Will Miao
2026-09-17 10:30:20 +08:00
parent 9eeebac40b
commit 369613f811
17 changed files with 231 additions and 16 deletions
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.", "hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.", "descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.", "hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
"openSettings": "Einstellungen öffnen" "openSettings": "Einstellungen öffnen",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+3 -2
View File
@@ -1241,11 +1241,12 @@
}, },
"noPaths": { "noPaths": {
"title": "No other-model folders found", "title": "No other-model folders found",
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.", "descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add the folder keys you need to the folder_paths section of your settings.json, then restart LoRA Manager.",
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.", "hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.", "descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.", "hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
"openSettings": "Open Settings" "openSettings": "Open Settings",
"openSettingsFolder": "Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.", "hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.", "descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.", "hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
"openSettings": "Abrir configuración" "openSettings": "Abrir configuración",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.", "hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés nexiste sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.", "descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés nexiste sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.", "hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
"openSettings": "Ouvrir les paramètres" "openSettings": "Ouvrir les paramètres",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.", "hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.", "descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.", "hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
"openSettings": "פתח הגדרות" "openSettings": "פתח הגדרות",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。", "hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。", "descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。", "hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
"openSettings": "設定を開く" "openSettings": "設定を開く",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.", "hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.", "descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.", "hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
"openSettings": "설정 열기" "openSettings": "설정 열기",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.", "hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.", "descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.", "hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
"openSettings": "Открыть настройки" "openSettings": "Открыть настройки",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。", "hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。", "descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。", "hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
"openSettings": "打开设置" "openSettings": "打开设置",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+2 -1
View File
@@ -1245,7 +1245,8 @@
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。", "hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。", "descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。", "hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
"openSettings": "開啟設定" "openSettings": "開啟設定",
"openSettingsFolder": "[TODO: Translate] Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
+17
View File
@@ -421,6 +421,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None:
return None return None
def _has_gui_display() -> bool:
"""Check whether a GUI session is reachable for xdg-open."""
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
class PromptServerProtocol(Protocol): class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers.""" """Subset of PromptServer used by the handlers."""
@@ -3393,6 +3398,18 @@ class FileSystemHandler:
subprocess.Popen(["open", "-R", settings_file]) subprocess.Popen(["open", "-R", settings_file])
else: else:
folder = os.path.dirname(settings_file) folder = os.path.dirname(settings_file)
if not _has_gui_display():
# Headless/SSH session: xdg-open cannot open a file
# manager, so hand the path to the browser for copying
# instead of reporting a success that never happened.
return web.json_response(
{
"success": True,
"message": "Headless session: path available for copying",
"path": settings_file,
"mode": "clipboard",
}
)
subprocess.Popen(["xdg-open", folder]) subprocess.Popen(["xdg-open", folder])
return web.json_response( return web.json_response(
+6 -1
View File
@@ -83,11 +83,16 @@ class OtherRoutes(BaseModelRoutes):
# resolved to no existing folder. Render an actionable empty state # resolved to no existing folder. Render an actionable empty state
# instead of an apparently broken empty grid. # instead of an apparently broken empty grid.
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
return { context = {
"other_disabled": False, "other_disabled": False,
"other_no_paths": not bool(config.other_roots), "other_no_paths": not bool(config.other_roots),
"standalone_mode": standalone_mode, "standalone_mode": standalone_mode,
} }
if standalone_mode:
# The settings UI cannot edit primary folder_paths, so the empty
# state must point at the actual file the user has to edit.
context["settings_file"] = getattr(self._settings, "settings_file", "") or ""
return context
def _get_expected_model_types(self) -> str: def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages""" """Get expected model types string for error messages"""
+45 -3
View File
@@ -7,8 +7,10 @@ import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels.
* empty state whose button turns the feature on; the backend then rebuilds the * empty state whose button turns the feature on; the backend then rebuilds the
* other-model roots and starts scanning, so a reload lands on the real page. * other-model roots and starts scanning, so a reload lands on the real page.
* *
* The same module backs the "enabled but no folders found" state, where the * The same module backs the "enabled but no folders found" state: ComfyUI
* only useful action is jumping to Settings instead of enabling anything. * mode points to the Settings page's Library section, while standalone mode
* (where the settings UI cannot edit primary folder paths) reveals the
* settings.json file the user must edit instead.
*/ */
async function handleEnableClick() { async function handleEnableClick() {
const button = document.getElementById('enableOtherModelsBtn'); const button = document.getElementById('enableOtherModelsBtn');
@@ -32,6 +34,41 @@ function handleOpenSettingsClick(event) {
openOtherModelsSettings(); openOtherModelsSettings();
} }
/**
* Open the settings.json location from the standalone no-folders state.
* The settings UI cannot edit primary folder_paths, so the only useful
* action is revealing the file itself (or copying its path in Docker).
*/
async function handleOpenSettingsFolderClick() {
const button = document.getElementById('openSettingsFolderBtn');
if (!button || button.disabled) return;
button.disabled = true;
try {
const response = await fetch('/api/lm/settings/open-location', { method: 'POST' });
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
if (data.mode === 'clipboard' && data.path) {
try {
await navigator.clipboard.writeText(data.path);
showToast('settings.openSettingsFileLocation.copied', { path: data.path }, 'success');
} catch (clipboardError) {
console.warn('Clipboard API not available:', clipboardError);
showToast('settings.openSettingsFileLocation.clipboardFallback', { path: data.path }, 'info');
}
} else {
showToast('settings.openSettingsFileLocation.success', {}, 'success');
}
} catch (error) {
console.error('Failed to open settings location:', error);
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
} finally {
button.disabled = false;
}
}
async function initializeOtherDisabledPage() { async function initializeOtherDisabledPage() {
// appCore.initialize() wires the shared header (theme, settings modal, // appCore.initialize() wires the shared header (theme, settings modal,
// language) so this page is not a dead end. // language) so this page is not a dead end.
@@ -46,8 +83,13 @@ async function initializeOtherDisabledPage() {
if (settingsButton) { if (settingsButton) {
settingsButton.addEventListener('click', handleOpenSettingsClick); settingsButton.addEventListener('click', handleOpenSettingsClick);
} }
const settingsFolderButton = document.getElementById('openSettingsFolderBtn');
if (settingsFolderButton) {
settingsFolderButton.addEventListener('click', handleOpenSettingsFolderClick);
}
} }
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage); document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage }; export { handleEnableClick as enableOtherModels, handleOpenSettingsFolderClick, initializeOtherDisabledPage };
+20 -1
View File
@@ -63,6 +63,19 @@
background: rgba(127, 127, 127, 0.15); background: rgba(127, 127, 127, 0.15);
border: 1px solid rgba(127, 127, 127, 0.25); border: 1px solid rgba(127, 127, 127, 0.25);
} }
.other-settings-file {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.other-settings-file code {
padding: 4px 8px;
border-radius: 4px;
background: rgba(127, 127, 127, 0.15);
border: 1px solid rgba(127, 127, 127, 0.25);
word-break: break-all;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -127,6 +140,9 @@
<h2>{{ t('other.noPaths.title') }}</h2> <h2>{{ t('other.noPaths.title') }}</h2>
{% if standalone_mode %} {% if standalone_mode %}
<p>{{ t('other.noPaths.descriptionStandalone') }}</p> <p>{{ t('other.noPaths.descriptionStandalone') }}</p>
{% if settings_file %}
<p class="other-settings-file"><i class="fas fa-file-alt"></i> <code>{{ settings_file }}</code></p>
{% endif %}
<pre class="other-no-paths-config"><code>"folder_paths": { <pre class="other-no-paths-config"><code>"folder_paths": {
"vae": ["/path/to/vae"], "vae": ["/path/to/vae"],
"upscale_models": ["/path/to/upscale_models"], "upscale_models": ["/path/to/upscale_models"],
@@ -135,13 +151,16 @@
"controlnet": ["/path/to/controlnet"] "controlnet": ["/path/to/controlnet"]
}</code></pre> }</code></pre>
<p class="other-disabled-hint">{{ t('other.noPaths.hintStandalone') }}</p> <p class="other-disabled-hint">{{ t('other.noPaths.hintStandalone') }}</p>
<button id="openSettingsFolderBtn" type="button">
<i class="fas fa-folder-open"></i> {{ t('other.noPaths.openSettingsFolder') }}
</button>
{% else %} {% else %}
<p>{{ t('other.noPaths.descriptionComfyUI') }}</p> <p>{{ t('other.noPaths.descriptionComfyUI') }}</p>
<p class="other-disabled-hint">{{ t('other.noPaths.hintComfyUI') }}</p> <p class="other-disabled-hint">{{ t('other.noPaths.hintComfyUI') }}</p>
{% endif %}
<button id="openOtherModelsSettingsBtn" type="button"> <button id="openOtherModelsSettingsBtn" type="button">
<i class="fas fa-cog"></i> {{ t('other.noPaths.openSettings') }} <i class="fas fa-cog"></i> {{ t('other.noPaths.openSettings') }}
</button> </button>
{% endif %}
</div> </div>
{% else %} {% else %}
<div class="sticky-topbar"> <div class="sticky-topbar">
@@ -25,6 +25,7 @@ describe('Other Models disabled page', () => {
document.body.innerHTML = [ document.body.innerHTML = [
'<button id="enableOtherModelsBtn"></button>', '<button id="enableOtherModelsBtn"></button>',
'<button id="openOtherModelsSettingsBtn"></button>', '<button id="openOtherModelsSettingsBtn"></button>',
'<button id="openSettingsFolderBtn"></button>',
].join(''); ].join('');
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
@@ -64,6 +65,52 @@ describe('Other Models disabled page', () => {
expect(showModal).toHaveBeenCalledWith('settingsModal'); expect(showModal).toHaveBeenCalledWith('settingsModal');
}); });
it('reveals the settings.json location from the standalone no-folders state', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, message: 'Opened settings folder' }),
});
const button = document.getElementById('openSettingsFolderBtn');
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/settings/open-location',
expect.objectContaining({ method: 'POST' }),
);
expect(showToastMock).toHaveBeenCalledWith(
'settings.openSettingsFileLocation.success',
{},
'success',
);
expect(button.disabled).toBe(false);
});
it('copies the settings path to the clipboard in Docker mode', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, mode: 'clipboard', path: '/data/settings.json' }),
});
document.getElementById('openSettingsFolderBtn').dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
expect(writeText).toHaveBeenCalledWith('/data/settings.json');
expect(showToastMock).toHaveBeenCalledWith(
'settings.openSettingsFileLocation.copied',
{ path: '/data/settings.json' },
'success',
);
});
it('enables Other Models through the settings API and reloads', async () => { it('enables Other Models through the settings API and reloads', async () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
+56
View File
@@ -532,6 +532,62 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert calls == [["xdg-open", str(backup_dir)]] assert calls == [["xdg-open", str(backup_dir)]]
@pytest.mark.asyncio
async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
"""Without a GUI session xdg-open cannot work; the handler must hand the
path to the browser instead of reporting a success that never happened."""
settings_file = tmp_path / "settings" / "settings.json"
settings_file.parent.mkdir(parents=True, exist_ok=True)
settings_file.write_text("{}", encoding="utf-8")
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(settings_file)))
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
popen_calls = []
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
response = await handler.open_settings_location(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["mode"] == "clipboard"
assert payload["path"] == str(settings_file)
assert popen_calls == []
@pytest.mark.asyncio
async def test_open_settings_location_with_display_opens_folder(tmp_path, monkeypatch):
settings_file = tmp_path / "settings" / "settings.json"
settings_file.parent.mkdir(parents=True, exist_ok=True)
settings_file.write_text("{}", encoding="utf-8")
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(settings_file)))
monkeypatch.setenv("DISPLAY", ":0")
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
calls = []
def fake_popen(args):
calls.append(args)
return MagicMock()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
response = await handler.open_settings_location(FakeRequest()) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert calls == [["xdg-open", str(settings_file.parent)]]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch): async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch):
wildcards_dir = tmp_path / "settings" / "wildcards" wildcards_dir = tmp_path / "settings" / "wildcards"
+19
View File
@@ -121,6 +121,25 @@ def test_page_context_reports_feature_state(monkeypatch):
assert provider(None) == {"other_disabled": True, "other_no_paths": False} assert provider(None) == {"other_disabled": True, "other_no_paths": False}
def test_page_context_exposes_settings_file_in_standalone(monkeypatch):
"""Standalone users must edit settings.json by hand; the empty state
needs the real file path to point them at."""
from py.config import config
from py.services.settings_manager import get_settings_manager
manager = get_settings_manager()
handler = OtherRoutes()
provider = handler._get_page_context_provider()
monkeypatch.setattr(config, "other_roots", [], raising=False)
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
context = provider(None)
assert context["other_no_paths"] is True
assert context["standalone_mode"] is True
assert context["settings_file"] == manager.settings_file
def test_get_expected_model_types_mentions_supported_types(): def test_get_expected_model_types_mentions_supported_types():
expected = OtherRoutes()._get_expected_model_types() expected = OtherRoutes()._get_expected_model_types()
for name in ("VAE", "Upscaler", "TextEncoder", "CLIPVision", "Controlnet"): for name in ("VAE", "Upscaler", "TextEncoder", "CLIPVision", "Controlnet"):