feat(modal): open model metadata sidecar location from the model modal

Centralized sidecar storage moves .metadata.json into a mirror tree, so
the Location link in the model modal could no longer reveal a model's
sidecar. The Location row now shows an inline metadata icon button
(centralized mode only) that calls a new
POST /api/lm/models/open-sidecar-location endpoint: the backend resolves
the sidecar through py/utils/sidecar_paths, reveals the .metadata.json
selected in the file manager (falling back to the sidecar directory when
it has not been written yet), and returns the path for clipboard mode on
headless/Docker sessions. The platform reveal logic is factored into a
shared FileSystemHandler._reveal_file helper. All 9 locales translated.
This commit is contained in:
Will Miao
2026-10-10 07:41:48 +08:00
parent 7937aeaa04
commit af90fe873d
17 changed files with 622 additions and 58 deletions
@@ -0,0 +1,215 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
MODAL_MODULE,
API_FACTORY,
UI_HELPERS_MODULE,
MODAL_MANAGER_MODULE,
SHOWCASE_MODULE,
MODEL_TAGS_MODULE,
UTILS_MODULE,
TRIGGER_WORDS_MODULE,
PRESET_TAGS_MODULE,
MODEL_VERSIONS_MODULE,
RECIPE_TAB_MODULE,
I18N_HELPERS_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({
MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname,
API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname,
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitai: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn((id, html) => {
document.body.innerHTML = `<div id="${id}">${html}</div>`;
}),
closeModal: vi.fn(),
},
}));
vi.mock(SHOWCASE_MODULE, () => ({
scrollToTop: vi.fn(),
loadExampleImages: vi.fn(),
}));
vi.mock(MODEL_TAGS_MODULE, () => ({
setupTagEditMode: vi.fn(),
}));
vi.mock(UTILS_MODULE, async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
renderCompactTags: vi.fn(() => ''),
setupTagTooltip: vi.fn(),
formatFileSize: vi.fn(() => '1 MB'),
};
});
vi.mock(TRIGGER_WORDS_MODULE, () => ({
renderTriggerWords: vi.fn(() => ''),
setupTriggerWordsEditMode: vi.fn(),
}));
vi.mock(PRESET_TAGS_MODULE, () => ({
parsePresets: vi.fn(() => ({})),
renderPresetTags: vi.fn(() => ''),
}));
vi.mock(MODEL_VERSIONS_MODULE, () => ({
initVersionsTab: vi.fn(() => ({
load: vi.fn().mockResolvedValue(undefined),
})),
}));
vi.mock(RECIPE_TAB_MODULE, () => ({
loadRecipesForModel: vi.fn(),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback || ''),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings'
}
}));
vi.mock(API_FACTORY, () => ({
getModelApiClient: vi.fn(),
}));
const MODEL_PATH = '/data/loras/flux/model.safetensors';
function makeModel(overrides = {}) {
return {
model_name: 'Sidecar Model',
file_path: MODEL_PATH,
file_name: 'model.safetensors',
civitai: {},
...overrides,
};
}
describe('Model modal sidecar location entry', () => {
let getModelApiClient;
let showToast;
let state;
let originalMode;
beforeEach(async () => {
document.body.innerHTML = '';
({ getModelApiClient } = await import(API_FACTORY));
({ showToast } = await import(UI_HELPERS_MODULE));
({ state } = await import(STATE_MODULE));
getModelApiClient.mockReset();
showToast.mockReset();
getModelApiClient.mockReturnValue({
fetchModelMetadata: vi.fn().mockResolvedValue(null),
saveModelMetadata: vi.fn(),
});
originalMode = state.global.settings.sidecar_storage_mode;
global.fetch = vi.fn();
});
afterEach(() => {
state.global.settings.sidecar_storage_mode = originalMode;
vi.restoreAllMocks();
});
async function renderModal(model) {
const { showModelModal } = await import(MODAL_MODULE);
await showModelModal(model, 'loras');
}
it('renders the metadata location button only in centralized sidecar mode', async () => {
state.global.settings.sidecar_storage_mode = 'centralized';
await renderModal(makeModel());
const button = document.querySelector('[data-action="open-sidecar-location"]');
expect(button).not.toBeNull();
expect(button.dataset.filepath).toBe(MODEL_PATH);
});
it.each(['alongside', '', undefined])('hides the button when sidecar mode is %s', async (mode) => {
state.global.settings.sidecar_storage_mode = mode;
await renderModal(makeModel());
expect(document.querySelector('[data-action="open-sidecar-location"]')).toBeNull();
});
it('posts the model file path to the sidecar endpoint on click', async () => {
state.global.settings.sidecar_storage_mode = 'centralized';
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await renderModal(makeModel());
document.querySelector('[data-action="open-sidecar-location"]').click();
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/models/open-sidecar-location',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ file_path: MODEL_PATH }),
})
);
expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.success', {}, 'success');
});
it('copies the resolved sidecar path in clipboard mode', async () => {
state.global.settings.sidecar_storage_mode = 'centralized';
const sidecarPath = '/sidecars/loras-abcd1234/flux/model.metadata.json';
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true, mode: 'clipboard', path: sidecarPath }),
});
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
await renderModal(makeModel());
document.querySelector('[data-action="open-sidecar-location"]').click();
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
expect(writeText).toHaveBeenCalledWith(sidecarPath);
expect(showToast).toHaveBeenCalledWith(
'modals.model.openSidecarLocation.copied',
{ path: sidecarPath },
'success'
);
});
it('shows an error toast when the endpoint fails', async () => {
state.global.settings.sidecar_storage_mode = 'centralized';
global.fetch.mockResolvedValue({ ok: false, status: 404 });
await renderModal(makeModel());
document.querySelector('[data-action="open-sidecar-location"]').click();
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.failed', {}, 'error');
});
});
+145
View File
@@ -911,6 +911,151 @@ async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monk
assert run_calls == []
def _patch_linux_desktop(monkeypatch):
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)
@pytest.mark.asyncio
async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch):
"""Centralized mode: the resolved .metadata.json is revealed selected,
even though it lives in the mirror tree rather than next to the model."""
model_file = tmp_path / "model.safetensors"
model_file.write_text("x", encoding="utf-8")
metadata_file = tmp_path / "mirror" / "model.metadata.json"
metadata_file.parent.mkdir(parents=True)
metadata_file.write_text("{}", encoding="utf-8")
handler = FileSystemHandler(settings_service=SimpleNamespace())
_patch_linux_desktop(monkeypatch)
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.get_metadata_path",
lambda path: str(metadata_file),
)
run_calls = []
popen_calls = []
monkeypatch.setattr(
subprocess, "run", lambda *args, **kwargs: run_calls.append(args[0]) or SimpleNamespace(returncode=0)
)
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
request = FakeRequest(json_data={"file_path": str(model_file)})
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["path"] == str(metadata_file)
assert len(run_calls) == 1
assert any("model.metadata.json" in arg for arg in run_calls[0])
assert popen_calls == []
@pytest.mark.asyncio
async def test_open_model_sidecar_location_falls_back_to_sidecar_dir(tmp_path, monkeypatch):
"""When the sidecar has not been written yet, open the sidecar directory
itself instead of reporting an error."""
model_file = tmp_path / "model.safetensors"
model_file.write_text("x", encoding="utf-8")
sidecar_dir = tmp_path / "mirror"
sidecar_dir.mkdir()
handler = FileSystemHandler(settings_service=SimpleNamespace())
_patch_linux_desktop(monkeypatch)
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.get_metadata_path",
lambda path: str(sidecar_dir / "model.metadata.json"),
)
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.get_sidecar_dir",
lambda path: str(sidecar_dir),
)
popen_calls = []
monkeypatch.setattr(
subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args[0])
)
request = FakeRequest(json_data={"file_path": str(model_file)})
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["path"] == str(sidecar_dir)
assert popen_calls == [["xdg-open", str(sidecar_dir)]]
@pytest.mark.asyncio
async def test_open_model_sidecar_location_missing_sidecar_returns_404(tmp_path, monkeypatch):
model_file = tmp_path / "model.safetensors"
model_file.write_text("x", encoding="utf-8")
missing_dir = tmp_path / "missing"
handler = FileSystemHandler(settings_service=SimpleNamespace())
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.get_metadata_path",
lambda path: str(missing_dir / "model.metadata.json"),
)
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.get_sidecar_dir",
lambda path: str(missing_dir),
)
request = FakeRequest(json_data={"file_path": str(model_file)})
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 404
assert payload["success"] is False
@pytest.mark.asyncio
async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
model_file = tmp_path / "model.safetensors"
model_file.write_text("x", encoding="utf-8")
metadata_file = tmp_path / "mirror" / "model.metadata.json"
metadata_file.parent.mkdir(parents=True)
metadata_file.write_text("{}", encoding="utf-8")
handler = FileSystemHandler(settings_service=SimpleNamespace())
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)
monkeypatch.setattr(
"py.routes.handlers.misc_handlers.get_metadata_path",
lambda path: str(metadata_file),
)
popen_calls = []
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
request = FakeRequest(json_data={"file_path": str(model_file)})
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["mode"] == "clipboard"
assert payload["path"] == str(metadata_file)
assert popen_calls == []
@pytest.mark.asyncio
async def test_open_model_sidecar_location_validates_input(tmp_path):
handler = FileSystemHandler(settings_service=SimpleNamespace())
response = await handler.open_model_sidecar_location(FakeRequest()) # pyright: ignore[reportArgumentType]
assert response.status == 400
request = FakeRequest(json_data={"file_path": str(tmp_path / "missing.safetensors")})
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
assert response.status == 404
class RecordingRouter:
def __init__(self):
self.calls = []