mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(settings): directory picker and live validation for path settings
Add a reusable directory-picker modal backed by a new generic POST /api/lm/browse-directory endpoint (browse logic extracted from the recipe batch-import handler into py/utils/directory_browser.py) and wire a browse button plus advisory validate-path feedback (POST /api/lm/validate-path) into the settings path inputs: recipes path, example images path/local root, and the extra-folder/model-path rows. The browse button insets into the right edge of static inputs so narrow settings rows keep their single-control layout. Translations for the new settings.directoryPicker and settings.pathValidation keys are filled in for all 9 locales.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Reusable directory picker modal backed by POST /api/lm/browse-directory.
|
||||
* Self-managed (NOT registered with ModalManager): it stacks above the
|
||||
* settings modal, so ModalManager's "close current modal on open" behavior
|
||||
* would kill the modal underneath.
|
||||
*/
|
||||
class DirectoryPickerModal {
|
||||
constructor() {
|
||||
this.isOpen = false;
|
||||
this.currentPath = '';
|
||||
this.parentPath = null;
|
||||
this.onSelect = null;
|
||||
this.elements = {};
|
||||
this._bindings = [];
|
||||
}
|
||||
|
||||
open({ initialPath = '', onSelect } = {}) {
|
||||
this._cacheElements();
|
||||
if (!this.elements.modal) {
|
||||
console.warn('DirectoryPickerModal: #directoryPickerModal not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
this._unbindEvents();
|
||||
this.onSelect = typeof onSelect === 'function' ? onSelect : null;
|
||||
this.currentPath = '';
|
||||
this.parentPath = null;
|
||||
this._clearError();
|
||||
this.elements.folderList.innerHTML = '';
|
||||
this.elements.currentPathEl.textContent = '';
|
||||
this.elements.upBtn.disabled = true;
|
||||
this.elements.pathInput.value = initialPath || '';
|
||||
|
||||
this._bindEvents();
|
||||
document.body.classList.add('modal-open');
|
||||
this.elements.modal.style.display = 'block';
|
||||
this.isOpen = true;
|
||||
|
||||
// An empty path lets the server pick its default (user home).
|
||||
this.loadDirectory(initialPath || '');
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
this.isOpen = false;
|
||||
this._unbindEvents();
|
||||
if (this.elements.modal) {
|
||||
this.elements.modal.style.display = 'none';
|
||||
}
|
||||
this.onSelect = null;
|
||||
// Keep body.modal-open: the settings modal underneath may still be open.
|
||||
}
|
||||
|
||||
async loadDirectory(path) {
|
||||
try {
|
||||
const response = await fetch('/api/lm/browse-directory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this._clearError();
|
||||
this._renderDirectory(data);
|
||||
} else {
|
||||
this._showError(data.error || translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading directory:', error);
|
||||
this._showError(translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
|
||||
}
|
||||
}
|
||||
|
||||
_cacheElements() {
|
||||
const modal = document.getElementById('directoryPickerModal');
|
||||
this.elements = {
|
||||
modal,
|
||||
closeBtn: document.getElementById('directoryPickerCloseBtn'),
|
||||
pathInput: document.getElementById('directoryPickerPathInput'),
|
||||
goBtn: document.getElementById('directoryPickerGoBtn'),
|
||||
upBtn: document.getElementById('directoryPickerUpBtn'),
|
||||
currentPathEl: document.getElementById('directoryPickerCurrentPath'),
|
||||
folderList: document.getElementById('directoryPickerFolderList'),
|
||||
errorEl: document.getElementById('directoryPickerError'),
|
||||
selectBtn: document.getElementById('directoryPickerSelectBtn')
|
||||
};
|
||||
}
|
||||
|
||||
_bind(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
this._bindings.push([target, type, handler, options]);
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
const { modal, closeBtn, pathInput, goBtn, upBtn, selectBtn } = this.elements;
|
||||
|
||||
this._bind(closeBtn, 'click', () => this.close());
|
||||
this._bind(goBtn, 'click', () => this.loadDirectory(pathInput.value.trim()));
|
||||
this._bind(pathInput, 'keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.loadDirectory(pathInput.value.trim());
|
||||
}
|
||||
});
|
||||
this._bind(upBtn, 'click', () => {
|
||||
// Server-provided parent_path: Windows paths cannot be derived client-side.
|
||||
if (this.parentPath) {
|
||||
this.loadDirectory(this.parentPath);
|
||||
}
|
||||
});
|
||||
this._bind(selectBtn, 'click', () => this._selectCurrent());
|
||||
|
||||
// Capture phase + stopPropagation so an ESC here never reaches the
|
||||
// settings modal's own ESC handler underneath.
|
||||
this._bind(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Backdrop click (the .modal element itself, not its content).
|
||||
this._bind(modal, 'click', (event) => {
|
||||
if (event.target === modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_unbindEvents() {
|
||||
for (const [target, type, handler, options] of this._bindings) {
|
||||
target.removeEventListener(type, handler, options);
|
||||
}
|
||||
this._bindings = [];
|
||||
}
|
||||
|
||||
_renderDirectory(data) {
|
||||
this.currentPath = data.current_path || '';
|
||||
this.parentPath = data.parent_path || null;
|
||||
|
||||
this.elements.currentPathEl.textContent = this.currentPath;
|
||||
this.elements.pathInput.value = this.currentPath;
|
||||
this.elements.upBtn.disabled = !this.parentPath;
|
||||
|
||||
const folderList = this.elements.folderList;
|
||||
folderList.innerHTML = '';
|
||||
|
||||
const directories = data.directories || [];
|
||||
if (directories.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'directory-picker-empty';
|
||||
empty.textContent = translate('settings.directoryPicker.emptyFolder', {}, 'This folder is empty');
|
||||
folderList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
directories.forEach((entry) => {
|
||||
folderList.appendChild(this._createFolderItem(entry));
|
||||
});
|
||||
}
|
||||
|
||||
// Each entry is { name, path, is_parent }; the server supplies the full
|
||||
// child path, so navigation never joins path segments client-side.
|
||||
_createFolderItem(entry) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'folder-item';
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-folder"></i>
|
||||
<span class="item-name">${this._escapeHtml(entry.name)}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
this.loadDirectory(entry.path);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
_selectCurrent() {
|
||||
if (!this.currentPath) return;
|
||||
if (this.onSelect) {
|
||||
this.onSelect(this.currentPath);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
_showError(message) {
|
||||
this.elements.errorEl.textContent = message;
|
||||
this.elements.errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
_clearError() {
|
||||
this.elements.errorEl.textContent = '';
|
||||
this.elements.errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
_escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
export const directoryPickerModal = new DirectoryPickerModal();
|
||||
export { DirectoryPickerModal };
|
||||
Reference in New Issue
Block a user