diff --git a/static/js/components/shared/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js index 4dcfab46..d4df2add 100644 --- a/static/js/components/shared/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -7,6 +7,88 @@ import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js'; import { showToast } from '../../utils/uiHelpers.js'; import { getModelApiClient } from '../../api/modelApiFactory.js'; +/** + * Resolve the active file path for the currently open model modal. + * Falls back to the provided value when DOM state has not been initialised yet. + * @param {string} fallback - Optional fallback path + * @returns {string} + */ +function getActiveModalFilePath(fallback = '') { + const modalElement = document.getElementById('modelModal'); + if (modalElement && modalElement.dataset && modalElement.dataset.filePath) { + return modalElement.dataset.filePath; + } + + const fileNameContent = document.querySelector('.file-name-content'); + if (fileNameContent && fileNameContent.dataset && fileNameContent.dataset.filePath) { + return fileNameContent.dataset.filePath; + } + + return fallback; +} + +/** + * Update all modal controls that cache the current model file path. + * Keeps metadata interactions in sync after renames or moves. + * @param {string} newFilePath - Updated model file path + */ +function updateModalFilePathReferences(newFilePath) { + if (!newFilePath) { + return; + } + + const modalElement = document.getElementById('modelModal'); + if (modalElement) { + modalElement.dataset.filePath = newFilePath; + modalElement.setAttribute('data-file-path', newFilePath); + } + + const modelNameContent = document.querySelector('.model-name-content'); + if (modelNameContent && modelNameContent.dataset) { + modelNameContent.dataset.filePath = newFilePath; + modelNameContent.setAttribute('data-file-path', newFilePath); + } + + const baseModelContent = document.querySelector('.base-model-content'); + if (baseModelContent && baseModelContent.dataset) { + baseModelContent.dataset.filePath = newFilePath; + baseModelContent.setAttribute('data-file-path', newFilePath); + } + + const fileNameContent = document.querySelector('.file-name-content'); + if (fileNameContent && fileNameContent.dataset) { + fileNameContent.dataset.filePath = newFilePath; + fileNameContent.setAttribute('data-file-path', newFilePath); + } + + const editTagsBtn = document.querySelector('.edit-tags-btn'); + if (editTagsBtn) { + editTagsBtn.dataset.filePath = newFilePath; + editTagsBtn.setAttribute('data-file-path', newFilePath); + } + + const editTriggerWordsBtn = document.querySelector('.edit-trigger-words-btn'); + if (editTriggerWordsBtn) { + editTriggerWordsBtn.dataset.filePath = newFilePath; + editTriggerWordsBtn.setAttribute('data-file-path', newFilePath); + } + + document.querySelectorAll('[data-action="open-file-location"]').forEach((el) => { + el.dataset.filepath = newFilePath; + el.setAttribute('data-filepath', newFilePath); + }); + + document.querySelectorAll('[data-file-path]').forEach((el) => { + el.dataset.filePath = newFilePath; + el.setAttribute('data-file-path', newFilePath); + }); + + document.querySelectorAll('[data-filepath]').forEach((el) => { + el.dataset.filepath = newFilePath; + el.setAttribute('data-filepath', newFilePath); + }); +} + /** * Set up model name editing functionality * @param {string} filePath - File path @@ -110,9 +192,9 @@ export function setupModelNameEditing(filePath) { } try { - // Get the file path from the dataset - const filePath = this.dataset.filePath; - + // Resolve current file path from modal state + const filePath = getActiveModalFilePath(this.dataset.filePath); + await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName }); showToast('toast.models.nameUpdatedSuccessfully', {}, 'success'); @@ -230,11 +312,8 @@ export function setupBaseModelEditing(filePath) { // Only save if the value has actually changed if (valueChanged || baseModelContent.textContent.trim() !== originalValue) { - // Get file path from the dataset - const filePath = baseModelContent.dataset.filePath; - - // Save the changes, passing the original value for comparison - saveBaseModel(filePath, originalValue); + const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath); + saveBaseModel(resolvedPath, originalValue); } // Remove this event listener @@ -277,9 +356,14 @@ async function saveBaseModel(filePath, originalValue) { if (newBaseModel === originalValue) { return; // No change, no need to save } + + const resolvedPath = getActiveModalFilePath(filePath); + if (!resolvedPath) { + return; + } try { - await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel }); + await getModelApiClient().saveModelMetadata(resolvedPath, { base_model: newBaseModel }); showToast('toast.models.baseModelUpdated', {}, 'success'); } catch (error) { @@ -396,10 +480,22 @@ export function setupFileNameEditing(filePath) { } try { - // Get the file path from the dataset - const filePath = this.dataset.filePath; - - await getModelApiClient().renameModelFile(filePath, newFileName); + const currentFilePath = getActiveModalFilePath(this.dataset.filePath); + const result = await getModelApiClient().renameModelFile(currentFilePath, newFileName); + + if (result && result.success && result.new_file_path) { + const newFilePath = result.new_file_path; + this.dataset.filePath = newFilePath; + this.setAttribute('data-file-path', newFilePath); + + const modalElement = document.getElementById('modelModal'); + if (modalElement) { + modalElement.dataset.filePath = newFilePath; + modalElement.setAttribute('data-file-path', newFilePath); + } + + updateModalFilePathReferences(newFilePath); + } } catch (error) { console.error('Error renaming file:', error); this.textContent = originalValue; // Restore original file name diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 30164a8f..d1cca163 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -21,6 +21,14 @@ import { initVersionsTab } from './ModelVersionsTab.js'; import { loadRecipesForLora } from './RecipeTab.js'; import { translate } from '../../utils/i18nHelpers.js'; +function getModalFilePath(fallback = '') { + const modalElement = document.getElementById('modelModal'); + if (modalElement && modalElement.dataset && modalElement.dataset.filePath) { + return modalElement.dataset.filePath; + } + return fallback; +} + /** * Display the model modal with the given model data * @param {Object} model - Model data object @@ -269,6 +277,10 @@ export async function showModelModal(model, modelType) { }; modalManager.showModal(modalId, content, null, onCloseCallback); + const activeModalElement = document.getElementById(modalId); + if (activeModalElement) { + activeModalElement.dataset.filePath = modelWithFullData.file_path || ''; + } const versionsTabController = initVersionsTab({ modalId, modelType, @@ -374,7 +386,7 @@ function setupEventHandlers(filePath) { } break; case 'open-file-location': - const filePath = target.dataset.filepath; + const filePath = target.dataset.filepath || getModalFilePath(); if (filePath) { openFileLocation(filePath); } @@ -423,7 +435,7 @@ function setupEditableFields(filePath, modelType) { return; } e.preventDefault(); - await saveNotes(filePath); + await saveNotes(); } }); } @@ -439,6 +451,7 @@ function setupLoraSpecificFields(filePath) { const presetValue = document.getElementById('preset-value'); const addPresetBtn = document.querySelector('.add-preset-btn'); const presetTags = document.querySelector('.preset-tags'); + const resolveFilePath = () => getModalFilePath(filePath); if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return; @@ -466,13 +479,16 @@ function setupLoraSpecificFields(filePath) { if (!key || !value) return; - const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`); + const currentPath = resolveFilePath(); + if (!currentPath) return; + const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) || + document.querySelector(`.model-card[data-filepath="${filePath}"]`); const currentPresets = parsePresets(loraCard?.dataset.usage_tips); currentPresets[key] = parseFloat(value); const newPresetsJson = JSON.stringify(currentPresets); - await getModelApiClient().saveModelMetadata(filePath, { usage_tips: newPresetsJson }); + await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson }); presetTags.innerHTML = renderPresetTags(currentPresets); @@ -491,10 +507,13 @@ function setupLoraSpecificFields(filePath) { } /** - * Save model notes - * @param {string} filePath - Path to the model file + * Save model notes using the current modal file path. */ -async function saveNotes(filePath) { +async function saveNotes() { + const filePath = getModalFilePath(); + if (!filePath) { + return; + } const content = document.querySelector('.notes-content').textContent; try { await getModelApiClient().saveModelMetadata(filePath, { notes: content }); diff --git a/tests/frontend/components/modelMetadata.renamePath.test.js b/tests/frontend/components/modelMetadata.renamePath.test.js new file mode 100644 index 00000000..280f8853 --- /dev/null +++ b/tests/frontend/components/modelMetadata.renamePath.test.js @@ -0,0 +1,196 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + METADATA_MODULE, + 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, +} = vi.hoisted(() => ({ + METADATA_MODULE: new URL('../../../static/js/components/shared/ModelMetadata.js', import.meta.url).pathname, + 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, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + openCivitai: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn((id, html) => { + document.body.innerHTML = `
${html}
`; + }), + closeModal: vi.fn(), + }, +})); + +vi.mock(SHOWCASE_MODULE, () => ({ + toggleShowcase: vi.fn(), + setupShowcaseScroll: vi.fn(), + scrollToTop: vi.fn(), + loadExampleImages: vi.fn(), +})); + +vi.mock(MODEL_TAGS_MODULE, () => ({ + setupTagEditMode: vi.fn(), +})); + +vi.mock(UTILS_MODULE, () => ({ + 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, () => ({ + loadRecipesForLora: vi.fn(), +})); + +vi.mock(I18N_HELPERS_MODULE, () => ({ + translate: vi.fn((_, __, fallback) => fallback || ''), +})); + +vi.mock(API_FACTORY, () => ({ + getModelApiClient: vi.fn(), +})); + +describe('Model metadata interactions keep file path in sync', () => { + let getModelApiClient; + + beforeEach(async () => { + document.body.innerHTML = ''; + ({ getModelApiClient } = await import(API_FACTORY)); + getModelApiClient.mockReset(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('updates modal references after renaming the model file', async () => { + const renameModelFile = vi.fn().mockResolvedValue({ + success: true, + new_file_path: 'new/models/Qwen.testing.safetensors', + }); + + getModelApiClient.mockReturnValue({ + renameModelFile, + saveModelMetadata: vi.fn(), + }); + + document.body.innerHTML = ` +
+
+

Qwen

+ +
+
+ SDXL + +
+
+ Qwen + +
+
+
+
+ +
+ +
+ `; + + const { setupFileNameEditing } = await import(METADATA_MODULE); + + setupFileNameEditing('models/Qwen.safetensors'); + + const fileNameContent = document.querySelector('.file-name-content'); + fileNameContent.setAttribute('contenteditable', 'true'); + fileNameContent.dataset.originalValue = 'Qwen'; + fileNameContent.textContent = 'Qwen.testing'; + + fileNameContent.dispatchEvent(new FocusEvent('blur')); + + await vi.waitFor(() => { + expect(renameModelFile).toHaveBeenCalledWith('models/Qwen.safetensors', 'Qwen.testing'); + }); + await Promise.resolve(); + await renameModelFile.mock.results[0].value; + expect(document.getElementById('modelModal').dataset.filePath).toBe('new/models/Qwen.testing.safetensors'); + expect(document.querySelector('.model-name-content').dataset.filePath).toBe('new/models/Qwen.testing.safetensors'); + expect(document.querySelector('.base-model-content').dataset.filePath).toBe('new/models/Qwen.testing.safetensors'); + expect(document.querySelector('.file-name-content').dataset.filePath).toBe('new/models/Qwen.testing.safetensors'); + expect(document.querySelector('.edit-tags-btn').dataset.filePath).toBe('new/models/Qwen.testing.safetensors'); + expect(document.querySelector('.edit-trigger-words-btn').dataset.filePath).toBe('new/models/Qwen.testing.safetensors'); + expect(document.querySelector('[data-action="open-file-location"]').dataset.filepath).toBe('new/models/Qwen.testing.safetensors'); + }); + + it('uses the latest file path when saving notes', async () => { + const saveModelMetadata = vi.fn().mockResolvedValue({ success: true }); + const fetchModelMetadata = vi.fn().mockResolvedValue(null); + + getModelApiClient.mockReturnValue({ + fetchModelMetadata, + saveModelMetadata, + }); + + const { showModelModal } = await import(MODAL_MODULE); + + await showModelModal( + { + model_name: 'Qwen', + file_path: 'models/Qwen.safetensors', + file_name: 'Qwen.safetensors', + civitai: {}, + }, + 'loras', + ); + + const modalElement = document.getElementById('modelModal'); + modalElement.dataset.filePath = 'models/Qwen.testing.safetensors'; + + const notesContent = document.querySelector('.notes-content'); + notesContent.textContent = 'Updated notes'; + notesContent.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + await vi.waitFor(() => { + expect(saveModelMetadata).toHaveBeenCalledWith('models/Qwen.testing.safetensors', { notes: 'Updated notes' }); + }); + }); +});