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:
Will Miao
2026-09-18 21:05:32 +08:00
parent 6dc9f34f7d
commit d45a523fb5
24 changed files with 1958 additions and 158 deletions
@@ -0,0 +1,179 @@
/* Directory Picker Modal */
/* Stacks above the settings modal: settings tooltips/combobox panels sit at
10000/10002, so 10010 keeps the picker on top of everything settings-side. */
#directoryPickerModal {
z-index: 10010;
}
.directory-picker-content {
max-width: 560px;
display: flex;
flex-direction: column;
}
.directory-picker-content h3 {
color: var(--text-color);
margin-bottom: var(--space-2);
}
/* Manual path row */
#directoryPickerModal .directory-picker-path-row {
display: flex;
gap: 8px;
margin-bottom: var(--space-2);
}
#directoryPickerModal .directory-picker-path-row input {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 0.9em;
}
#directoryPickerModal .directory-picker-path-row input:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
/* Directory browser (class names shared with the batch import browser) */
#directoryPickerModal .directory-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
overflow: hidden;
}
#directoryPickerModal .browser-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
#directoryPickerModal .back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: var(--transition-base);
}
#directoryPickerModal .back-btn:hover {
border-color: var(--lora-accent);
background: var(--bg-color);
}
#directoryPickerModal .back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#directoryPickerModal .current-path {
flex: 1;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#directoryPickerModal .browser-content {
max-height: 300px;
overflow-y: auto;
padding: 12px;
}
#directoryPickerModal .folder-list {
display: flex;
flex-direction: column;
gap: 4px;
}
#directoryPickerModal .folder-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: var(--transition-base);
border: 1px solid transparent;
}
#directoryPickerModal .folder-item:hover {
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
border-color: var(--lora-accent);
}
#directoryPickerModal .folder-item i {
color: #fbbf24;
font-size: 1.1em;
}
#directoryPickerModal .item-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#directoryPickerModal .browser-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 10px 12px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
#directoryPickerModal .directory-picker-error {
margin-top: 8px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
background: oklch(from var(--lora-error) l c h / 0.12);
color: var(--lora-error);
font-size: 0.85em;
word-break: break-word;
}
#directoryPickerModal .directory-picker-empty {
padding: var(--space-2);
text-align: center;
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
}
/* Dark theme adjustments */
[data-theme="dark"] #directoryPickerModal .directory-browser {
background: var(--card-bg);
}
[data-theme="dark"] #directoryPickerModal .browser-header,
[data-theme="dark"] #directoryPickerModal .browser-footer {
background: var(--lora-surface);
}
[data-theme="dark"] #directoryPickerModal .folder-item i {
color: #fcd34d;
}
@@ -1692,6 +1692,87 @@ input:checked + .toggle-slider:before {
color: white;
}
/* Browse (directory picker) button — boxed accent style used on the dynamic
extra-folder-path / model-path rows, mirroring .remove-path-btn. Static
path fields use the .inset variant below instead. */
#settingsModal .browse-path-btn {
width: 32px;
height: 32px;
padding: 0;
border-radius: var(--border-radius-xs);
border: 1px solid var(--lora-accent);
background: transparent;
color: var(--lora-accent);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
flex-shrink: 0;
}
#settingsModal .browse-path-btn:hover {
background: var(--lora-accent);
color: white;
}
/* Inset variant (static path fields): the button floats inside the right
edge of the input, so the setting row keeps its single-control look and
narrow columns never push it onto a second line. */
#settingsModal .browse-path-btn.inset {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-color);
opacity: 0.55;
}
#settingsModal .browse-path-btn.inset:hover {
background: transparent;
color: var(--lora-accent);
opacity: 1;
}
#settingsModal input.has-inset-browse {
padding-right: 34px;
}
/* Advisory path validation feedback (wraps below the input row) */
#settingsModal .text-input-wrapper,
#settingsModal .path-control {
flex-wrap: wrap;
}
#settingsModal .path-control > .text-input-wrapper {
flex: 1;
min-width: 0;
}
.path-validation {
display: none;
flex-basis: 100%;
width: 100%;
margin-top: 4px;
font-size: 0.8em;
line-height: 1.4;
color: var(--lora-error);
}
.path-validation.visible {
display: flex;
align-items: center;
gap: 6px;
}
.path-validation.valid {
color: var(--lora-success);
}
/* Highlight animation for setting items targeted from Doctor actions */
@keyframes settings-highlight-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
+1
View File
@@ -18,6 +18,7 @@
@import 'components/modal/example-access-modal.css';
@import 'components/modal/support-modal.css';
@import 'components/modal/download-modal.css';
@import 'components/modal/directory-picker-modal.css';
@import 'components/toast.css';
@import 'components/loading.css';
@import 'components/menu.css';
@@ -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 };
+191
View File
@@ -15,9 +15,17 @@ import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js';
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
const PATH_VALIDATION_ERROR_I18N = {
path_not_found: { key: 'settings.pathValidation.pathNotFound', fallback: 'Path does not exist' },
not_a_directory: { key: 'settings.pathValidation.notADirectory', fallback: 'Not a directory' },
not_readable: { key: 'settings.pathValidation.notReadable', fallback: 'Path is not readable' },
not_writable: { key: 'settings.pathValidation.notWritable', fallback: 'Path is not writable' },
};
// Other-model sub_type -> i18n label key, mirroring the checkbox list in
// templates/components/modals/settings/library.html.
const OTHER_SUB_TYPE_LABEL_KEYS = {
@@ -1198,6 +1206,24 @@ export class SettingsManager {
if (useNewLicenseIconsCheckbox) {
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
}
// Directory browse buttons + advisory path validation (idempotent,
// safe to call on every modal open).
this.attachPathField('recipesPath', {
onAfterSelect: () => this.saveInputSetting('recipesPath', 'recipes_path'),
});
this.attachPathField('exampleImagesPath', {
onAfterSelect: (pickedPath) => {
// Mirror ExampleImagesManager's blur-save flow.
window.exampleImagesManager?.updateDownloadButtonState?.(pickedPath.trim() !== '');
this.saveSetting('example_images_path', pickedPath)
.then(() => showToast('toast.exampleImages.pathUpdated', {}, 'success'))
.catch((error) => showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error'));
},
});
this.attachPathField('exampleImagesLocalRoot', {
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
});
}
loadDownloadBackendSettings() {
@@ -1806,6 +1832,11 @@ export class SettingsManager {
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
onfocus="settingsManager.clearExtraFolderPathError(this)"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="browse-path-btn"
onclick="settingsManager.browseForPathRow(this, '${modelType}')"
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
<i class="fas fa-folder-open"></i>
</button>
<button type="button" class="remove-path-btn"
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
@@ -2242,6 +2273,11 @@ export class SettingsManager {
onblur="settingsManager.updateModelFolderPaths('${key}')"
onfocus="settingsManager.clearModelFolderPathError(this)"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="browse-path-btn"
onclick="settingsManager.browseForPathRow(this, '${key}', true)"
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
<i class="fas fa-folder-open"></i>
</button>
<button type="button" class="remove-path-btn"
onclick="settingsManager.removeModelFolderPathRow(this, '${key}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
@@ -3763,6 +3799,161 @@ export class SettingsManager {
}
}
// ── Directory picker + live path validation ─────────────────────────
// Validation is advisory only: it never blocks or alters save flows.
attachPathField(inputId, { expect = 'directory', onAfterSelect } = {}) {
const input = document.getElementById(inputId);
if (!input) {
console.warn(`SettingsManager.attachPathField: #${inputId} not found`);
return;
}
if (input.dataset.pathFieldAttached === '1') return;
input.dataset.pathFieldAttached = '1';
const browseBtn = document.createElement('button');
browseBtn.type = 'button';
browseBtn.className = 'browse-path-btn inset';
browseBtn.title = translate('settings.directoryPicker.title', {}, 'Browse Folders');
browseBtn.innerHTML = '<i class="fas fa-folder-open"></i>';
// Inset layout: the button is absolutely positioned inside the right
// edge of the input, so the row keeps its original single-control look.
const parent = input.parentElement;
let wrapper;
let statusHost;
if (parent && parent.classList.contains('path-control')) {
// e.g. #exampleImagesPath sits beside a Download button: wrap only
// the input so the button insets into it and Download stays beside it.
wrapper = document.createElement('div');
wrapper.className = 'text-input-wrapper';
parent.insertBefore(wrapper, input);
wrapper.appendChild(input);
statusHost = parent;
} else {
// .text-input-wrapper provided by the setting_input macro
wrapper = parent;
statusHost = parent;
}
wrapper.appendChild(browseBtn);
input.classList.add('has-inset-browse');
const statusEl = document.createElement('div');
statusEl.className = 'path-validation';
statusHost.appendChild(statusEl);
input._pathFieldConfig = { expect, onAfterSelect, statusEl };
browseBtn.addEventListener('click', () => this.browseForPath(inputId));
input.addEventListener('blur', () => {
clearTimeout(input._pathValidationTimer);
this.validatePath(input, statusEl, expect);
});
input.addEventListener('input', () => {
clearTimeout(input._pathValidationTimer);
input._pathValidationTimer = setTimeout(() => {
this.validatePath(input, statusEl, expect);
}, 500);
});
}
async validatePath(input, statusEl, expect = 'directory') {
const value = input.value.trim();
const seq = input._pathValidationSeq = (input._pathValidationSeq || 0) + 1;
if (!value) {
this._clearPathStatus(statusEl);
return;
}
let data;
try {
const response = await fetch('/api/lm/validate-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: value, expect }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
data = await response.json();
} catch (error) {
if (seq === input._pathValidationSeq) {
this._clearPathStatus(statusEl);
}
return;
}
// Ignore responses overtaken by newer input or validation runs.
if (seq !== input._pathValidationSeq || input.value.trim() !== value) {
return;
}
if (data.success && !data.error_code) {
statusEl.innerHTML = `<i class="fas fa-check-circle"></i><span>${translate('settings.pathValidation.valid', {}, 'Path is valid')}</span>`;
statusEl.classList.add('visible', 'valid');
} else {
const message = this._getPathValidationMessage(data);
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i><span></span>';
statusEl.querySelector('span').textContent = message;
statusEl.classList.add('visible');
statusEl.classList.remove('valid');
}
}
_getPathValidationMessage(data) {
const entry = PATH_VALIDATION_ERROR_I18N[data?.error_code];
if (entry) {
return translate(entry.key, {}, entry.fallback);
}
return data?.error || translate('settings.pathValidation.pathNotFound', {}, 'Path does not exist');
}
_clearPathStatus(statusEl) {
statusEl.classList.remove('visible', 'valid');
statusEl.textContent = '';
}
browseForPath(inputId, { onAfterSelect } = {}) {
const input = document.getElementById(inputId);
if (!input) return;
const config = input._pathFieldConfig || {};
const afterSelect = onAfterSelect || config.onAfterSelect;
directoryPickerModal.open({
initialPath: input.value.trim(),
onSelect: (pickedPath) => {
input.value = pickedPath;
if (config.statusEl) {
this.validatePath(input, config.statusEl, config.expect || 'directory');
}
if (typeof afterSelect === 'function') {
afterSelect(pickedPath);
}
},
});
}
// Browse variant for the dynamic extra/model folder path rows: picking a
// folder routes through the row's existing validation + save logic.
browseForPathRow(btn, key, isModelPath = false) {
const row = btn.closest('.extra-folder-path-row');
const input = row ? row.querySelector('.extra-folder-path-input') : null;
if (!input) return;
directoryPickerModal.open({
initialPath: input.value.trim(),
onSelect: (pickedPath) => {
input.value = pickedPath;
if (isModelPath) {
this.updateModelFolderPaths(key);
} else {
this.updateExtraFolderPaths(key);
}
},
});
}
async saveInputSetting(elementId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;