Compare commits

...
2 Commits
Author SHA1 Message Date
RaresKeYandLéo f7cbc885c1 fix(docker): migrate retained SearXNG settings (#6055)
* fix(docker): migrate retained SearXNG settings

Retained nonempty SearXNG settings can miss defaults required by newer pinned images while bypassing the entrypoint's narrow regeneration checks.

Add an atomic PyYAML-aware migration to all Compose variants. Preserve existing inheritance choices, custom content, secrets, ownership, and mode while inserting only the missing top-level default-inheritance key.

Validated with 39 focused and adjacent tests, compile checks, and fresh and retained pinned-image HTTP 200 gates. Full repository CI remains for the PR.

* fix(docker): chmod the settings temp file before chowning it

The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/DAC_OVERRIDE
and carries no FOWNER, and searxng's own entrypoint chowns /etc/searxng to
searxng:searxng, so every retained settings file belongs to that user by the
second boot. Chowning the temporary file first left root unable to chmod it,
so the migration exited 1 and `set -eu` killed the container before
`exec /usr/local/searxng/entrypoint.sh` — SearXNG never started and odysseus
blocked on its healthcheck.

Swap the two calls so the chmod lands while the temporary file is still
root-owned, and cover the ordering with a test that refuses the chmod once
the chown has happened, the way the kernel does.

* fix(docker): let searxng boot when the settings migration fails

The migration runs under `set -eu`, so any settings file it cannot parse or
rewrite took the container down instead of merely going unmigrated. A symlinked
/etc/searxng/settings.yml is enough: the migration refuses a non-regular file
and searxng, which reads through the symlink perfectly well, never got to start.

Guard the call with `|| true` in all three Compose variants. The failure still
prints its reason on stderr, and searxng is left to report anything genuinely
wrong with the file.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-16 04:17:58 +02:00
Alexandre Teixeira cee319050c refactor(settings): add registry-backed navigation and finder (#6040)
* refactor(settings): add modular shell primitives

* refactor(settings): wire modular shell

* test(settings): exercise real coordinator ESM boundary

* refactor(settings): add registry-backed settings finder

* fix(settings): harden registry navigation behavior
2026-08-16 02:48:19 +01:00
17 changed files with 3771 additions and 154 deletions
+5
View File
@@ -130,12 +130,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+5
View File
@@ -133,12 +133,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+5
View File
@@ -111,12 +111,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Make retained SearXNG settings inherit defaults without replacing them."""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import yaml
from yaml.nodes import MappingNode
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
_UTF8_BOM = b"\xef\xbb\xbf"
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
"""Parse settings with the same safe YAML semantics SearXNG uses."""
try:
loaded = yaml.safe_load(text)
node = yaml.compose(text, Loader=yaml.SafeLoader)
except yaml.YAMLError:
raise ValueError("settings file is not valid single-document YAML") from None
if loaded is None and node is None:
return None, {}
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
raise ValueError("settings root is not a mapping")
return node, loaded
def _flow_mapping_start(text: str) -> int:
"""Return the root flow mapping's opening-brace character offset."""
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if isinstance(token, FlowMappingStartToken):
return token.start_mark.index
except yaml.YAMLError:
pass
raise ValueError("flow-style settings mapping has no opening brace")
def _newline_for(contents: bytes) -> bytes:
first_lf = contents.find(b"\n")
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
return b"\r\n"
return b"\n"
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
"""Return a safe character offset and indent for a root block mapping key."""
if root is None:
return len(text), 0
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if not isinstance(token, BlockMappingStartToken):
continue
line_start = token.start_mark.index - token.start_mark.column
if not text[line_start : token.start_mark.index].strip():
return line_start, token.start_mark.column
return root.end_mark.index, token.start_mark.column
except yaml.YAMLError:
pass
return root.end_mark.index, root.start_mark.column
def _add_block_default_inheritance(
contents: bytes, text: str, root: MappingNode | None
) -> bytes:
newline = _newline_for(contents)
character_offset, indent_width = _block_mapping_position(text, root)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[:character_offset].encode("utf-8"))
separator = b""
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
separator = newline
addition = (
separator
+ b" " * indent_width
+ b"use_default_settings: true"
+ newline
)
return contents[:offset] + addition + contents[offset:]
def migrate_settings(path: Path) -> bool:
"""Add the missing inheritance key atomically; return whether the file changed."""
source_stat = path.lstat()
if not stat.S_ISREG(source_stat.st_mode):
raise ValueError(f"settings path is not a regular file: {path}")
contents = path.read_bytes()
if not contents:
return False
text = contents.decode("utf-8-sig")
root, loaded = _parse_root_mapping(text)
if "use_default_settings" in loaded:
return False
if root is not None and root.flow_style:
start = _flow_mapping_start(text)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[: start + 1].encode("utf-8"))
separator = b", " if root.value else b""
updated = (
contents[:offset]
+ b"use_default_settings: true"
+ separator
+ contents[offset:]
)
else:
updated = _add_block_default_inheritance(contents, text, root)
fd, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.odysseus-", dir=path.parent
)
temporary = Path(temporary_name)
try:
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
# file belongs to searxng:searxng — which every retained settings file
# does, because searxng's entrypoint chowns /etc/searxng — root can no
# longer chmod it and the migration dies with EPERM.
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
with os.fdopen(fd, "wb") as handle:
fd = -1
handle.write(updated)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if fd >= 0:
os.close(fd)
temporary.unlink(missing_ok=True)
return True
def main(argv: list[str]) -> int:
if len(argv) > 2:
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
return 2
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
try:
changed = migrate_settings(path)
except (OSError, UnicodeError, ValueError) as exc:
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
return 1
if changed:
print("Added use_default_settings inheritance to retained SearXNG settings")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+51 -1
View File
@@ -1421,6 +1421,55 @@
</div>
<div class="settings-layout">
<div class="settings-sidebar">
<button
type="button"
class="settings-sidebar-toggle"
id="settings-sidebar-toggle"
aria-label="Collapse settings navigation"
title="Collapse settings navigation"
>
<svg class="settings-sidebar-toggle-collapse" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<svg class="settings-sidebar-toggle-expand" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div
class="settings-sidebar-resize-handle"
id="settings-sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize settings navigation"
aria-valuemin="150"
aria-valuemax="340"
aria-valuenow="220"
tabindex="0"
></div>
<div class="settings-sidebar-content">
<div class="settings-nav-search-wrap">
<svg class="settings-nav-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-4-4"></path>
</svg>
<input
type="search"
id="settings-nav-search"
class="settings-nav-search"
placeholder="Find settings…"
autocomplete="off"
aria-label="Find settings"
aria-controls="settings-nav-search-results"
/>
<div
id="settings-nav-search-results"
class="settings-nav-search-results hidden"
role="listbox"
aria-label="Settings search results"
></div>
</div>
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
<button class="settings-nav-item active" data-settings-tab="services">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
@@ -1481,9 +1530,10 @@
<span>Users</span>
</button>
<button class="settings-nav-item admin-only" data-settings-tab="system">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"/></svg>
<span>System</span>
</button>
</div>
</div>
<div class="settings-panels">
+83 -150
View File
@@ -3,8 +3,25 @@
import uiModule from './ui.js';
import searchModule from './search.js';
import { makeWindowDraggable } from './windowDrag.js';
import { clearDockSide } from './modalSnap.js';
import { byId } from './settings/dom.js';
import {
getSettingsRegistryIssues,
isAdminManagedSettingsTab,
} from './settings/registry.js';
import { bindSettingsSearch } from './settings/search.js';
import { bindSettingsSidebar } from './settings/sidebar.js';
import {
activateSettingsPanel,
getActiveSettingsTab,
bindSettingsNavigation,
} from './settings/navigation.js';
import {
bindSettingsDrag,
bindSettingsClose,
bindOpenPromptModalLink,
showSettingsModal,
hideSettingsModal,
} from './settings/lifecycle.js';
import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
@@ -14,133 +31,29 @@ let initialized = false;
let modalEl = null;
let _authPolicy = { password_min_length: 8 };
function el(id) { return document.getElementById(id); }
const el = byId;
function esc(s) { return uiModule.esc(s); }
function safeRasterDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
/* ── Tab switching ── */
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
/* ── Settings shell coordination ── */
function onSettingsPanelActivated(tab) {
// Appearance keeps its existing transparent preview behavior.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
function initTabs() {
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.settingsTab;
// Lazy-init admin when first clicking an admin tab
if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return;
}
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
// Mark when the Appearance tab is open so the modal can go
// semi-transparent — lets the user see the rest of the UI react as
// they flip toggles instead of having to close + reopen the modal.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
if (tab === 'ai') refreshAiModelEndpoints();
});
});
// AI endpoints are intentionally refreshed only when entering the AI panel.
if (tab === 'ai') refreshAiModelEndpoints();
}
/* ── Dragging ── */
function initDrag() {
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
// Skip interactive controls in the header (e.g. the opacity slider) so
// grabbing them doesn't start a window-drag.
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
});
}
function resetWindowPlacement() {
const content = modalEl && modalEl.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
function openAdminSettingsTab(tab) {
if (window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return true;
}
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(prop => content.style.removeProperty(prop));
}
/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */
function initOpenPromptModalLink() {
document.addEventListener('click', async (e) => {
const link = e.target.closest('[data-open-prompt-modal]');
if (!link) return;
e.preventDefault();
// Close settings first so the prompt modal isn't stacked on top.
if (modalEl && !modalEl.classList.contains('hidden')) close();
try {
const m = await import('./presets.js');
const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal);
if (typeof fn === 'function') fn();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
// Force the Persona tab (data-chartab="character") since the link's
// whole purpose is editing personas — not landing on Inject by default.
const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]');
if (personaTab) personaTab.click();
});
}
/* ── Close on backdrop / X ── */
function initClose() {
modalEl.querySelector('.close-btn').addEventListener('click', close);
modalEl.addEventListener('mousedown', e => {
if (uiModule.isTouchInsideModal()) return;
if (e.target === modalEl) close();
});
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
// Bail when a transient popover inside the modal is open — Esc should
// dismiss just that, not the whole modal. Same-document listeners fire
// in registration order regardless of capture/bubble, so the popover's
// own handler can't pre-empt ours; we have to opt out here.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) {
return;
}
// If an integration edit/add form is open inside the modal, close
// just that — don't dismiss the whole settings modal. (Pressing
// ESC mid-edit and losing the modal was a fast-typing footgun.)
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
e.preventDefault();
e.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
e.preventDefault();
e.stopPropagation();
close();
});
return false;
}
/* ── Appearance-tab opacity slider ──
@@ -2238,10 +2151,39 @@ function initAccount() {
function initAll() {
modalEl = el('settings-modal');
initTabs();
initDrag();
initClose();
initOpenPromptModalLink();
bindSettingsNavigation(modalEl, {
openAdminTab: openAdminSettingsTab,
onPanelActivated: onSettingsPanelActivated,
});
bindSettingsSearch(modalEl, {
isAdmin: () => !!window._isAdmin,
openPanel(tab) {
const button = modalEl.querySelector(`[data-settings-tab="${tab}"]`);
if (button) button.click();
},
});
bindSettingsSidebar(modalEl);
const registryIssues = getSettingsRegistryIssues(modalEl);
if (registryIssues.length) {
console.warn('Settings registry/DOM mismatch:', registryIssues);
}
bindSettingsDrag(modalEl);
bindSettingsClose(modalEl, {
closeSettings: close,
isTouchInsideModal: () => uiModule.isTouchInsideModal(),
});
bindOpenPromptModalLink({
getModal: () => modalEl,
closeSettings: close,
});
initOpacityToggle();
initialized = true;
initDefaultChat();
@@ -5661,44 +5603,35 @@ function syncAdminVisibility() {
═══════════════════════════════════════════ */
export function open(tab) {
if (!initialized) initAll();
syncAppearanceCheckboxes();
if (modalEl.classList.contains('hidden')) {
resetWindowPlacement();
}
modalEl.classList.remove('hidden');
showSettingsModal(modalEl);
syncAdminVisibility();
const content = modalEl.querySelector('.settings-modal-content');
if (tab) {
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
activateSettingsPanel(modalEl, tab);
}
// Auto-init admin data if showing an admin tab
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
syncAppearanceOpacity(activeTab === 'appearance');
if (activeTab === 'ai') refreshAiModelEndpoints();
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
// Preserve existing panel-specific side effects when Settings is opened
// directly to a tab as well as when the user navigates there.
const activeTab = tab || getActiveSettingsTab(modalEl);
onSettingsPanelActivated(activeTab);
// Auto-init admin data if showing an admin tab.
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
window.adminModule._initData();
}
}
export function close() {
if (!modalEl) return;
// Always clear the appearance-tab body class so the rest of the app
// doesn't keep its dimmed state if the modal got closed mid-tab.
// Always clear the Appearance state so the rest of the app does not remain
// dimmed if Settings is closed while that panel is active.
document.body.classList.remove('settings-appearance-open');
syncAppearanceOpacity(false); // clear any opacity-slider fade
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
} else {
modalEl.classList.add('hidden');
}
syncAppearanceOpacity(false);
hideSettingsModal(modalEl);
}
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
+7
View File
@@ -0,0 +1,7 @@
// Shared DOM helpers for the Settings modules.
// Keep this module intentionally small so panel modules do not grow their own
// element-lookup conventions as Settings is split out of settings.js.
export function byId(id) {
return document.getElementById(id);
}
+176
View File
@@ -0,0 +1,176 @@
// Settings modal lifecycle primitives.
//
// Panel-specific behavior belongs elsewhere. This module owns only the window
// shell: dragging/docking reset, close semantics, visibility animation, and the
// delegated link that leaves Settings for the Persona editor.
import { makeWindowDraggable } from '../windowDrag.js';
import { clearDockSide } from '../modalSnap.js';
const _dragBound = new WeakSet();
const _closeBound = new WeakSet();
let _promptLinkBound = false;
export function bindSettingsDrag(modalEl) {
if (!modalEl || _dragBound.has(modalEl)) return;
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
_dragBound.add(modalEl);
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
});
}
export function resetSettingsWindowPlacement(modalEl) {
const content = modalEl?.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
}
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(property => content.style.removeProperty(property));
}
export function bindOpenPromptModalLink({ getModal, closeSettings } = {}) {
if (_promptLinkBound) return;
_promptLinkBound = true;
document.addEventListener('click', async event => {
const link = event.target?.closest?.('[data-open-prompt-modal]');
if (!link) return;
event.preventDefault();
const settingsModal = typeof getModal === 'function' ? getModal() : null;
if (
settingsModal
&& !settingsModal.classList.contains('hidden')
&& typeof closeSettings === 'function'
) {
closeSettings();
}
try {
const module = await import('../presets.js');
const openPrompt = module.openCustomPresetModal
|| (module.default && module.default.openCustomPresetModal);
if (typeof openPrompt === 'function') openPrompt();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
const personaTab = document.querySelector(
'#custom-preset-modal .preset-tab[data-chartab="character"]'
);
if (personaTab) personaTab.click();
});
}
export function bindSettingsClose(modalEl, options = {}) {
if (!modalEl || _closeBound.has(modalEl)) return;
_closeBound.add(modalEl);
const closeSettings = options.closeSettings;
const isTouchInsideModal = options.isTouchInsideModal;
const closeButton = modalEl.querySelector('.close-btn');
closeButton?.addEventListener('click', () => {
if (typeof closeSettings === 'function') closeSettings();
});
modalEl.addEventListener('mousedown', event => {
if (typeof isTouchInsideModal === 'function' && isTouchInsideModal()) return;
if (event.target === modalEl && typeof closeSettings === 'function') {
closeSettings();
}
});
document.addEventListener('keydown', event => {
if (event.key !== 'Escape' || modalEl.classList.contains('hidden')) return;
// Esc should dismiss transient popovers before the Settings window.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (
popoverOpen
&& popoverOpen.style.display !== 'none'
&& !popoverOpen.classList.contains('hidden')
) {
return;
}
// Integration/account editors are nested flows. Close the editor first so
// an accidental Esc does not discard the entire Settings context.
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (
innerForm
&& innerForm.style.display !== 'none'
&& innerForm.children.length > 0
) {
event.preventDefault();
event.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
event.preventDefault();
event.stopPropagation();
if (typeof closeSettings === 'function') closeSettings();
});
}
export function showSettingsModal(modalEl) {
if (!modalEl) return;
if (modalEl.classList.contains('hidden')) {
resetSettingsWindowPlacement(modalEl);
}
modalEl.classList.remove('hidden');
}
export function hideSettingsModal(modalEl) {
if (!modalEl) return;
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => {
if (!modalEl.classList.contains('hidden')) {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}
}, 250);
return;
}
modalEl.classList.add('hidden');
}
+57
View File
@@ -0,0 +1,57 @@
// Settings navigation primitives.
//
// This module owns panel activation and sidebar click routing only. Individual
// panels continue to own their data loading and side effects.
import { DEFAULT_SETTINGS_PANEL_ID, isAdminManagedSettingsTab } from './registry.js';
const _boundModals = new WeakSet();
export function activateSettingsPanel(modalEl, tab) {
if (!modalEl || !tab) return null;
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.settingsTab === tab);
});
modalEl.querySelectorAll('[data-settings-panel]').forEach(panel => {
panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tab);
});
return tab;
}
export function getActiveSettingsTab(modalEl, fallback = DEFAULT_SETTINGS_PANEL_ID) {
if (!modalEl) return fallback;
const active = modalEl.querySelector('[data-settings-tab].active');
return active?.dataset?.settingsTab || fallback;
}
export function bindSettingsNavigation(modalEl, options = {}) {
if (!modalEl || _boundModals.has(modalEl)) return;
_boundModals.add(modalEl);
const openAdminTab = options.openAdminTab;
const onPanelActivated = options.onPanelActivated;
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
button.addEventListener('click', () => {
const tab = button.dataset.settingsTab;
if (!tab) return;
// Preserve the existing lazy-admin path: when the admin module accepts
// the tab, it owns activation/rendering and the Settings shell does not
// perform a second local switch.
if (
isAdminManagedSettingsTab(tab)
&& typeof openAdminTab === 'function'
&& openAdminTab(tab, button) === true
) {
return;
}
activateSettingsPanel(modalEl, tab);
if (typeof onPanelActivated === 'function') {
onPanelActivated(tab, button);
}
});
});
}
+237
View File
@@ -0,0 +1,237 @@
// Canonical metadata for the existing Settings information architecture.
//
// This module describes Settings; it does not render the sidebar, load panel
// data, or own panel behavior. Keeping those concerns separate lets the
// current markup remain stable while navigation/search code shares one source
// of truth for panel identity and ownership.
function defineGroup(definition) {
return Object.freeze({ ...definition });
}
function definePanel(definition) {
return Object.freeze({
controller: 'settings',
adminOnly: false,
...definition,
keywords: Object.freeze([...(definition.keywords || [])]),
});
}
export const SETTINGS_GROUPS = Object.freeze([
defineGroup({
id: 'models',
label: 'Models & AI',
}),
defineGroup({
id: 'communications',
label: 'Communications',
}),
defineGroup({
id: 'experience',
label: 'Experience',
}),
defineGroup({
id: 'account',
label: 'Account',
}),
defineGroup({
id: 'administration',
label: 'Administration',
adminOnly: true,
}),
]);
// Order intentionally mirrors the existing Settings sidebar.
export const SETTINGS_PANELS = Object.freeze([
definePanel({
id: 'services',
label: 'Add Models',
group: 'models',
controller: 'admin',
keywords: ['models', 'provider', 'endpoint'],
}),
definePanel({
id: 'added-models',
label: 'Added Models',
group: 'models',
controller: 'admin',
keywords: ['models', 'configured', 'provider', 'endpoint'],
}),
definePanel({
id: 'ai',
label: 'AI Defaults',
group: 'models',
keywords: ['ai', 'defaults', 'model', 'vision', 'image', 'tts', 'stt'],
}),
definePanel({
id: 'search',
label: 'Search',
group: 'models',
keywords: ['search', 'research', 'provider'],
}),
definePanel({
id: 'integrations',
label: 'Integrations',
group: 'communications',
controller: 'admin',
keywords: ['integrations', 'connections', 'services'],
}),
definePanel({
id: 'email',
label: 'Email',
group: 'communications',
keywords: ['email', 'imap', 'smtp', 'oauth'],
}),
definePanel({
id: 'reminders',
label: 'Reminders',
group: 'communications',
keywords: ['reminders', 'notifications', 'alerts'],
}),
definePanel({
id: 'appearance',
label: 'Appearance',
group: 'experience',
keywords: ['appearance', 'theme', 'font', 'density', 'peek'],
}),
definePanel({
id: 'shortcuts',
label: 'Shortcuts',
group: 'experience',
keywords: ['shortcuts', 'keyboard', 'hotkeys'],
}),
definePanel({
id: 'account',
label: 'Account',
group: 'account',
keywords: ['account', 'password', 'logout'],
}),
definePanel({
id: 'tools',
label: 'Agent Tools',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['agent', 'tools'],
}),
definePanel({
id: 'users',
label: 'Users',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['users', 'accounts', 'admin'],
}),
definePanel({
id: 'system',
label: 'System',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['system', 'admin', 'server'],
}),
]);
export const DEFAULT_SETTINGS_PANEL_ID = 'services';
const _panelsById = new Map(
SETTINGS_PANELS.map(panel => [panel.id, panel]),
);
export function getSettingsPanel(id) {
return _panelsById.get(String(id || '')) || null;
}
export function getSettingsPanelsForGroup(groupId) {
return SETTINGS_PANELS.filter(panel => panel.group === groupId);
}
export function isAdminManagedSettingsTab(id) {
return getSettingsPanel(id)?.controller === 'admin';
}
export function isAdminOnlySettingsTab(id) {
return getSettingsPanel(id)?.adminOnly === true;
}
export function getSettingsPanelSearchText(panelOrId) {
const panel = typeof panelOrId === 'string'
? getSettingsPanel(panelOrId)
: panelOrId;
if (!panel) return '';
return [
panel.label,
...(panel.keywords || []),
].join(' ').toLowerCase();
}
function normalizeSettingsSearch(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
export function searchSettingsPanels(query, options = {}) {
const normalized = normalizeSettingsSearch(query);
if (!normalized) return [];
const terms = normalized.split(' ');
const isAdmin = options.isAdmin === true;
return SETTINGS_PANELS.filter(panel => {
if (panel.adminOnly && !isAdmin) return false;
const haystack = getSettingsPanelSearchText(panel);
return terms.every(term => haystack.includes(term));
});
}
export function getSettingsRegistryIssues(modalEl) {
if (!modalEl) return ['Settings modal is unavailable'];
const tabIds = Array.from(
modalEl.querySelectorAll('[data-settings-tab]'),
element => element.dataset.settingsTab,
).filter(Boolean);
const panelIds = Array.from(
modalEl.querySelectorAll('[data-settings-panel]'),
element => element.dataset.settingsPanel,
).filter(Boolean);
const registryIds = SETTINGS_PANELS.map(panel => panel.id);
const issues = [];
const duplicates = ids => ids.filter(
(id, index) => ids.indexOf(id) !== index,
);
for (const id of new Set(duplicates(tabIds))) {
issues.push(`Duplicate Settings tab: ${id}`);
}
for (const id of new Set(duplicates(panelIds))) {
issues.push(`Duplicate Settings panel: ${id}`);
}
for (const id of registryIds) {
if (!tabIds.includes(id)) issues.push(`Registry tab missing from DOM: ${id}`);
if (!panelIds.includes(id)) issues.push(`Registry panel missing from DOM: ${id}`);
}
for (const id of tabIds) {
if (!registryIds.includes(id)) issues.push(`DOM tab missing from registry: ${id}`);
}
for (const id of panelIds) {
if (!registryIds.includes(id)) issues.push(`DOM panel missing from registry: ${id}`);
}
return issues;
}
+172
View File
@@ -0,0 +1,172 @@
import {
SETTINGS_GROUPS,
getSettingsPanel,
searchSettingsPanels,
} from './registry.js';
const _boundModals = new WeakSet();
function groupLabelFor(panel) {
const group = SETTINGS_GROUPS.find(candidate => candidate.id === panel.group);
return group?.label || '';
}
function clearResults(resultsEl) {
if (!resultsEl) return;
resultsEl.replaceChildren();
resultsEl.classList.add('hidden');
}
function getResultButtons(resultsEl) {
if (!resultsEl) return [];
return Array.from(resultsEl.querySelectorAll('[data-settings-search-result]'));
}
function setActiveResult(resultsEl, index) {
const buttons = getResultButtons(resultsEl);
if (!buttons.length) return -1;
let next = index;
if (next < 0) next = buttons.length - 1;
if (next >= buttons.length) next = 0;
buttons.forEach((button, buttonIndex) => {
const active = buttonIndex === next;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
if (typeof buttons[next].scrollIntoView === 'function') {
buttons[next].scrollIntoView({ block: 'nearest' });
}
return next;
}
export function bindSettingsSearch(modalEl, options = {}) {
if (!modalEl || _boundModals.has(modalEl)) return;
const input = modalEl.querySelector('#settings-nav-search');
const resultsEl = modalEl.querySelector('#settings-nav-search-results');
if (!input || !resultsEl) return;
_boundModals.add(modalEl);
const isAdmin = typeof options.isAdmin === 'function'
? options.isAdmin
: () => false;
const openPanel = typeof options.openPanel === 'function'
? options.openPanel
: () => {};
let activeIndex = -1;
function reset() {
input.value = '';
activeIndex = -1;
clearResults(resultsEl);
}
function activateResult(button) {
const panelId = button?.dataset?.settingsSearchResult;
if (!panelId || !getSettingsPanel(panelId)) return;
openPanel(panelId);
reset();
}
function render() {
const query = input.value.trim();
activeIndex = -1;
resultsEl.replaceChildren();
if (!query) {
resultsEl.classList.add('hidden');
return;
}
const matches = searchSettingsPanels(query, {
isAdmin: isAdmin(),
});
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'settings-search-empty';
empty.textContent = 'No settings found';
resultsEl.appendChild(empty);
resultsEl.classList.remove('hidden');
return;
}
for (const panel of matches) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'settings-search-result';
button.setAttribute('data-settings-search-result', panel.id);
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', 'false');
const label = document.createElement('span');
label.className = 'settings-search-result-label';
label.textContent = panel.label;
const group = document.createElement('span');
group.className = 'settings-search-result-group';
group.textContent = groupLabelFor(panel);
button.append(label, group);
button.addEventListener('click', () => activateResult(button));
resultsEl.appendChild(button);
}
resultsEl.classList.remove('hidden');
}
input.addEventListener('input', render);
input.addEventListener('focus', () => {
if (input.value.trim()) render();
});
input.addEventListener('keydown', event => {
const buttons = getResultButtons(resultsEl);
if (event.key === 'Escape') {
if (!input.value && resultsEl.classList.contains('hidden')) return;
event.preventDefault();
event.stopPropagation();
reset();
return;
}
if (!buttons.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex = setActiveResult(resultsEl, activeIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex = setActiveResult(resultsEl, activeIndex - 1);
return;
}
if (event.key === 'Enter') {
const target = buttons[activeIndex >= 0 ? activeIndex : 0];
if (!target) return;
event.preventDefault();
activateResult(target);
}
});
modalEl.addEventListener('mousedown', event => {
if (event.target === input || resultsEl.contains(event.target)) return;
clearResults(resultsEl);
activeIndex = -1;
});
return { reset, render };
}
+238
View File
@@ -0,0 +1,238 @@
const STORAGE_WIDTH = 'odysseus-settings-sidebar-width';
const STORAGE_COLLAPSED = 'odysseus-settings-sidebar-collapsed';
export const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 220;
export const SETTINGS_SIDEBAR_MIN_WIDTH = 150;
export const SETTINGS_SIDEBAR_MAX_WIDTH = 340;
export const SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD = 110;
const _bound = new WeakSet();
function clampWidth(value) {
const width = Number(value);
if (!Number.isFinite(width)) return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
return Math.max(
SETTINGS_SIDEBAR_MIN_WIDTH,
Math.min(SETTINGS_SIDEBAR_MAX_WIDTH, width),
);
}
function readStoredWidth() {
try {
const stored = localStorage.getItem(STORAGE_WIDTH);
if (stored == null || String(stored).trim() === '') {
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
}
return clampWidth(stored);
} catch {
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
}
}
function readStoredCollapsed() {
try {
return localStorage.getItem(STORAGE_COLLAPSED) === '1';
} catch {
return false;
}
}
function storeWidth(width) {
try {
localStorage.setItem(STORAGE_WIDTH, String(Math.round(width)));
} catch {}
}
function storeCollapsed(collapsed) {
try {
localStorage.setItem(STORAGE_COLLAPSED, collapsed ? '1' : '0');
} catch {}
}
function syncResizeHandleAria(modalEl, width = null) {
const handle = modalEl?.querySelector('#settings-sidebar-resize-handle');
if (!handle) return;
const sidebar = modalEl.querySelector('.settings-sidebar');
const collapsed = sidebar?.classList.contains('settings-sidebar-collapsed');
const current = collapsed
? SETTINGS_SIDEBAR_MIN_WIDTH
: clampWidth(
width ?? sidebar?.getBoundingClientRect?.().width
?? SETTINGS_SIDEBAR_DEFAULT_WIDTH
);
handle.setAttribute('aria-valuemin', String(SETTINGS_SIDEBAR_MIN_WIDTH));
handle.setAttribute('aria-valuemax', String(SETTINGS_SIDEBAR_MAX_WIDTH));
handle.setAttribute('aria-valuenow', String(Math.round(current)));
}
function isDesktopSidebarMode(modalEl) {
const content = modalEl?.querySelector('.settings-modal-content');
if (!content) return false;
// Mirrors the existing container breakpoint where the sidebar becomes a
// horizontal rail. Resizing/collapse applies only to the vertical desktop
// navigation layout.
return content.getBoundingClientRect().width > 620;
}
export function setSettingsSidebarCollapsed(modalEl, collapsed, options = {}) {
const sidebar = modalEl?.querySelector('.settings-sidebar');
if (!sidebar) return false;
const next = collapsed === true;
sidebar.classList.toggle('settings-sidebar-collapsed', next);
const toggle = sidebar.querySelector('#settings-sidebar-toggle');
if (toggle) {
toggle.setAttribute('aria-expanded', next ? 'false' : 'true');
toggle.setAttribute(
'aria-label',
next ? 'Expand settings navigation' : 'Collapse settings navigation',
);
toggle.title = next
? 'Expand settings navigation'
: 'Collapse settings navigation';
}
if (!next) {
const width = clampWidth(options.width ?? readStoredWidth());
sidebar.style.setProperty('--settings-sidebar-width', `${width}px`);
syncResizeHandleAria(modalEl, width);
} else {
syncResizeHandleAria(modalEl);
}
if (options.persist !== false) storeCollapsed(next);
return next;
}
export function setSettingsSidebarWidth(modalEl, width, options = {}) {
const sidebar = modalEl?.querySelector('.settings-sidebar');
if (!sidebar) return null;
const next = clampWidth(width);
sidebar.style.setProperty('--settings-sidebar-width', `${next}px`);
syncResizeHandleAria(modalEl, next);
if (options.persist !== false) storeWidth(next);
return next;
}
export function bindSettingsSidebar(modalEl) {
if (!modalEl || _bound.has(modalEl)) return;
_bound.add(modalEl);
const sidebar = modalEl.querySelector('.settings-sidebar');
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
if (!sidebar || !handle || !toggle) return;
setSettingsSidebarWidth(modalEl, readStoredWidth(), { persist: false });
setSettingsSidebarCollapsed(
modalEl,
readStoredCollapsed(),
{ persist: false },
);
toggle.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (!isDesktopSidebarMode(modalEl)) return;
const collapsed = sidebar.classList.contains('settings-sidebar-collapsed');
setSettingsSidebarCollapsed(modalEl, !collapsed);
});
let startX = 0;
let startWidth = 0;
function stopResize() {
if (!sidebar.classList.contains('settings-sidebar-resizing')) return;
sidebar.classList.remove('settings-sidebar-resizing');
document.body.classList.remove('settings-sidebar-resize-active');
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopResize);
const width = sidebar.getBoundingClientRect().width;
if (width < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
setSettingsSidebarCollapsed(modalEl, true);
return;
}
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
setSettingsSidebarWidth(modalEl, width);
storeCollapsed(false);
}
function onPointerMove(event) {
const rawWidth = startWidth + (event.clientX - startX);
if (rawWidth < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
sidebar.style.setProperty(
'--settings-sidebar-width',
`${Math.max(34, rawWidth)}px`,
);
return;
}
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
setSettingsSidebarWidth(modalEl, rawWidth, { persist: false });
}
handle.addEventListener('pointerdown', event => {
if (!isDesktopSidebarMode(modalEl)) return;
event.preventDefault();
startX = event.clientX;
startWidth = sidebar.getBoundingClientRect().width;
sidebar.classList.remove('settings-sidebar-collapsed');
sidebar.classList.add('settings-sidebar-resizing');
document.body.classList.add('settings-sidebar-resize-active');
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopResize);
});
handle.addEventListener('keydown', event => {
if (!isDesktopSidebarMode(modalEl)) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle.click();
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
if (sidebar.classList.contains('settings-sidebar-collapsed')) {
setSettingsSidebarCollapsed(modalEl, false);
}
const current = sidebar.getBoundingClientRect().width;
const delta = event.key === 'ArrowLeft' ? -16 : 16;
// Once keyboard resizing reaches the declared minimum, another ArrowLeft
// collapses the rail. Without this explicit boundary transition the width
// setter clamps 134px back to 150px forever, making keyboard collapse via
// ArrowLeft unreachable.
if (
event.key === 'ArrowLeft'
&& current <= SETTINGS_SIDEBAR_MIN_WIDTH
) {
setSettingsSidebarCollapsed(modalEl, true);
return;
}
setSettingsSidebarWidth(modalEl, current + delta);
});
}
+261 -3
View File
@@ -23605,7 +23605,7 @@ body.gallery-selecting .gallery-dl-btn,
/* ===== Settings Modal Layout ===== */
.settings-modal-content {
width: min(720px, 92vw);
width: min(1040px, 94vw);
max-height: 85vh;
padding: 0;
container-type: inline-size;
@@ -23659,10 +23659,13 @@ body.gallery-selecting .gallery-dl-btn,
}
.settings-sidebar {
width: 160px;
flex-shrink: 0;
position: relative;
width: var(--settings-sidebar-width, 220px);
flex: 0 0 var(--settings-sidebar-width, 220px);
min-width: 0;
border-right: 1px solid var(--border);
padding: 8px;
transition: width 0.16s ease, flex-basis 0.16s ease;
display: flex;
flex-direction: column;
gap: 2px;
@@ -41141,3 +41144,258 @@ body.theme-frosted .modal {
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
.compare-pane { min-height: 60dvh; }
}
/* ── Settings navigation / finder ────────────────────────────── */
.settings-sidebar-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
overflow-y: auto;
overflow-x: hidden;
}
.settings-nav-search-wrap {
position: relative;
margin: 0 0 8px;
}
.settings-nav-search {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: 32px;
padding: 5px 9px 5px 29px;
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
background: var(--bg);
color: var(--fg);
font: inherit;
font-size: 11px;
}
.settings-nav-search:focus {
border-color: var(--accent, var(--red));
}
.settings-nav-search::placeholder {
color: var(--fg);
opacity: 0.45;
}
.settings-nav-search-icon {
position: absolute;
z-index: 2;
top: 9px;
left: 9px;
pointer-events: none;
opacity: 0.5;
}
.settings-nav-search-results {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
min-width: 100%;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
.settings-search-result {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 9px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--fg);
font: inherit;
text-align: left;
cursor: pointer;
}
.settings-search-result:hover,
.settings-search-result.active {
background: color-mix(in srgb, var(--fg) 8%, transparent);
}
.settings-search-result-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.settings-search-result-group {
flex: 0 0 auto;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
opacity: 0.5;
}
.settings-search-empty {
padding: 8px;
color: var(--fg);
font-size: 10px;
text-align: center;
opacity: 0.55;
}
.settings-sidebar-resize-handle {
position: absolute;
z-index: 20;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
background: transparent;
touch-action: none;
}
.settings-sidebar-resize-handle:hover,
.settings-sidebar-resizing .settings-sidebar-resize-handle {
background: var(--accent, var(--red));
opacity: 0.45;
}
.settings-sidebar-resizing {
transition: none;
user-select: none;
}
body.settings-sidebar-resize-active {
cursor: col-resize;
user-select: none;
}
.settings-sidebar-toggle {
position: absolute;
z-index: 25;
top: 9px;
right: -13px;
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--panel);
color: var(--fg);
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease, border-color 0.12s ease;
}
.settings-sidebar:hover .settings-sidebar-toggle,
.settings-sidebar-toggle:focus-visible,
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
opacity: 1;
}
.settings-sidebar-toggle:hover,
.settings-sidebar-toggle:focus-visible {
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
.settings-sidebar-toggle-expand {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed {
width: 34px;
flex-basis: 34px;
padding: 0;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
top: 10px;
right: 5px;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-collapse {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-expand {
display: block;
}
/* The existing narrow/snapped layout is a horizontal tab rail. Sidebar
resizing/collapse applies only to the vertical desktop layout. */
@container settings-modal (max-width: 620px) {
.settings-sidebar {
width: auto;
flex: 0 0 auto;
transition: none;
}
.settings-sidebar-content {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
}
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
.settings-sidebar-toggle {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed {
width: auto;
flex-basis: auto;
padding: 6px;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
display: flex;
}
}
@media (max-width: 600px) {
.settings-sidebar {
width: auto;
flex: 0 0 auto;
}
.settings-sidebar-content {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
}
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
.settings-sidebar-toggle {
display: none;
}
}
+775
View File
@@ -0,0 +1,775 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
class ClassList {
constructor() { this.values = new Set(); }
add(...names) { names.filter(Boolean).forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) {
if (force === undefined) force = !this.contains(name);
force ? this.add(name) : this.remove(name);
return force;
}
}
class Style {
constructor() { this.values = {}; this.display = ''; this.cssText = ''; }
setProperty(name, value) { this.values[name] = value; this[name] = value; }
removeProperty(name) { delete this.values[name]; delete this[name]; }
}
class Element {
constructor(tagName, documentRef) {
this.tagName = String(tagName || 'div').toUpperCase();
this.ownerDocument = documentRef;
this.children = [];
this.parentElement = null;
this.attributes = {};
this.dataset = {};
this.classList = new ClassList();
this.style = new Style();
this._listeners = new Map();
this._innerHTML = '';
this._textContent = '';
}
set id(value) { this.attributes.id = String(value); }
get id() { return this.attributes.id || ''; }
set className(value) {
this.classList.values = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
get className() { return Array.from(this.classList.values).join(' '); }
set innerHTML(value) {
this._innerHTML = String(value || '');
if (!this._innerHTML) {
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
}
}
get innerHTML() { return this._innerHTML; }
set textContent(value) {
this._textContent = String(value ?? '');
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
}
get textContent() {
return this._textContent + this.children.map(child => child.textContent).join('');
}
setAttribute(name, value) {
const text = String(value);
this.attributes[name] = text;
if (name === 'class') this.className = text;
if (name.startsWith('data-')) {
const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
this.dataset[key] = text;
}
}
getAttribute(name) { return this.attributes[name] ?? null; }
appendChild(child) {
child.parentElement = this;
this.children.push(child);
return child;
}
append(...children) { children.forEach(child => this.appendChild(child)); }
replaceChildren(...children) {
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
children.forEach(child => this.appendChild(child));
}
contains(candidate) {
if (candidate === this) return true;
return descendants(this).includes(candidate);
}
getBoundingClientRect() {
const width = Number.parseFloat(
this.style.values['--settings-sidebar-width']
|| this.style.width
// This is only a desktop-mode fixture (>620px), not a CSS assertion.
|| (this.classList.contains('settings-modal-content') ? '800' : '220'),
);
return { width, height: 600, left: 0, right: width, top: 0, bottom: 600 };
}
addEventListener(type, handler, options = {}) {
if (!this._listeners.has(type)) this._listeners.set(type, []);
this._listeners.get(type).push({ handler, once: !!options.once });
}
removeEventListener(type, handler) {
const entries = this._listeners.get(type) || [];
this._listeners.set(type, entries.filter(entry => entry.handler !== handler));
}
dispatchEvent(event) {
event.target ||= this;
event.currentTarget = this;
const entries = [...(this._listeners.get(event.type) || [])];
for (const entry of entries) {
entry.handler.call(this, event);
if (entry.once) this.removeEventListener(event.type, entry.handler);
}
}
click() {
this.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} });
}
matches(selector) { return matchesSelector(this, selector); }
querySelector(selector) { return queryAll(this, selector)[0] || null; }
querySelectorAll(selector) { return queryAll(this, selector); }
closest(selector) {
let node = this;
while (node) {
if (matchesSelector(node, selector)) return node;
node = node.parentElement;
}
return null;
}
}
function simpleMatch(element, selector) {
const text = selector.trim();
if (!text) return false;
const id = text.match(/#([\w-]+)/);
if (id && element.id !== id[1]) return false;
for (const match of text.matchAll(/\.([\w-]+)/g)) {
if (!element.classList.contains(match[1])) return false;
}
for (const match of text.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
const actual = element.getAttribute(match[1]);
if (actual === null) return false;
if (match[2] !== undefined && actual !== match[2]) return false;
}
return true;
}
function matchesSelector(element, selector) {
return selector.split(',').some(part => simpleMatch(element, part));
}
function descendants(root) {
const result = [];
const visit = node => {
for (const child of node.children || []) {
result.push(child);
visit(child);
}
};
visit(root);
return result;
}
function queryAll(root, selector) {
const selectors = selector.split(',').map(item => item.trim()).filter(Boolean);
const nodes = descendants(root);
const result = [];
for (const candidate of nodes) {
if (selectors.some(sel => simpleMatch(candidate, sel))) result.push(candidate);
}
return result;
}
class DocumentShim {
constructor() {
this.body = new Element('body', this);
this.head = new Element('head', this);
this.listeners = new Map();
}
createElement(tag) { return new Element(tag, this); }
getElementById(id) {
return [this.body, this.head, ...descendants(this.body), ...descendants(this.head)]
.find(node => node.id === id) || null;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
return [...queryAll(this.body, selector), ...queryAll(this.head, selector)];
}
addEventListener(type, handler) {
if (!this.listeners.has(type)) this.listeners.set(type, []);
this.listeners.get(type).push(handler);
}
removeEventListener(type, handler) {
this.listeners.set(type, (this.listeners.get(type) || []).filter(item => item !== handler));
}
dispatch(type, event) {
for (const handler of [...(this.listeners.get(type) || [])]) handler(event);
}
}
function buildFixture(document) {
const modal = document.createElement('div');
modal.id = 'settings-modal';
document.body.appendChild(modal);
const header = document.createElement('div');
header.className = 'modal-header';
modal.appendChild(header);
const close = document.createElement('button');
close.className = 'close-btn';
header.appendChild(close);
const content = document.createElement('div');
content.className = 'settings-modal-content modal-content';
modal.appendChild(content);
const nav = document.createElement('div');
nav.className = 'settings-sidebar';
content.appendChild(nav);
const sidebarToggle = document.createElement('button');
sidebarToggle.id = 'settings-sidebar-toggle';
nav.appendChild(sidebarToggle);
const sidebarHandle = document.createElement('div');
sidebarHandle.id = 'settings-sidebar-resize-handle';
nav.appendChild(sidebarHandle);
const sidebarContent = document.createElement('div');
sidebarContent.className = 'settings-sidebar-content';
nav.appendChild(sidebarContent);
const finder = document.createElement('div');
sidebarContent.appendChild(finder);
const searchInput = document.createElement('input');
searchInput.id = 'settings-nav-search';
finder.appendChild(searchInput);
const searchResults = document.createElement('div');
searchResults.id = 'settings-nav-search-results';
searchResults.classList.add('hidden');
finder.appendChild(searchResults);
const panels = document.createElement('div');
content.appendChild(panels);
const makeTab = (id, active = false) => {
const button = document.createElement('button');
button.setAttribute('data-settings-tab', id);
if (active) button.classList.add('active');
sidebarContent.appendChild(button);
const panel = document.createElement('section');
panel.setAttribute('data-settings-panel', id);
if (!active) panel.classList.add('hidden');
panels.appendChild(panel);
return { button, panel };
};
const panelIds = [
'services',
'added-models',
'ai',
'search',
'integrations',
'email',
'reminders',
'appearance',
'shortcuts',
'account',
'tools',
'users',
'system',
];
const settingsPanels = Object.fromEntries(
panelIds.map((id, index) => [id, makeTab(id, index === 0)]),
);
return {
modal,
header,
close,
content,
services: settingsPanels.services,
appearance: settingsPanels.appearance,
system: settingsPanels.system,
settingsPanels,
searchInput,
searchResults,
sidebar: nav,
sidebarToggle,
sidebarHandle,
};
}
function moduleSource(relativePath) {
let source = fs.readFileSync(
path.join(__dirname, '../../static/js/settings', relativePath),
'utf8',
);
// The production files are real ES modules. This lightweight VM harness
// removes imports because dependencies are loaded into the same context,
// but each module still needs its own lexical scope so private const/let
// bindings do not collide across modules.
source = source.replace(/^\s*import[\s\S]*?;\s*$/gm, '');
// Preserve exported API on globalThis while keeping all non-exported
// bindings private inside the module block below.
source = source
.replace(
/\bexport\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g,
'globalThis.$1 = function $1(',
)
.replace(
/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
)
.replace(
/\bexport\s+let\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
)
.replace(
/\bexport\s+var\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
);
return `{\n${source}\n}`;
}
(function runTests() {
const document = new DocumentShim();
const fixture = buildFixture(document);
const dragCalls = [];
const dockCalls = [];
const removedWindowListeners = [];
const storage = new Map();
const context = {
console,
document,
localStorage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, String(value)); },
removeItem(key) { storage.delete(key); },
},
window: {
removeEventListener: (...args) => removedWindowListeners.push(args),
addEventListener() {},
},
makeWindowDraggable: (...args) => dragCalls.push(args),
clearDockSide: (...args) => dockCalls.push(args),
setTimeout: callback => { callback(); return 1; },
WeakSet,
};
vm.createContext(context);
vm.runInContext(moduleSource('registry.js'), context, { filename: 'registry.js' });
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
const results = [];
const check = (test, pass, detail = '') => results.push({ test, pass: Boolean(pass), detail });
const registryPanelIds = vm.runInContext(
'SETTINGS_PANELS.map(panel => panel.id).join(",")',
context,
);
const registryGroupIds = vm.runInContext(
'SETTINGS_GROUPS.map(group => group.id).join(",")',
context,
);
check(
'Settings registry preserves the existing sidebar panel order',
registryPanelIds === [
'services',
'added-models',
'ai',
'search',
'integrations',
'email',
'reminders',
'appearance',
'shortcuts',
'account',
'tools',
'users',
'system',
].join(','),
);
check(
'Settings registry defines the intended information-architecture groups',
registryGroupIds === [
'models',
'communications',
'experience',
'account',
'administration',
].join(','),
);
check(
'Settings registry keeps services, models, integrations and admin panels on the existing admin controller',
['services', 'added-models', 'integrations', 'tools', 'users', 'system']
.every(id => context.isAdminManagedSettingsTab(id))
&& ['ai', 'search', 'email', 'reminders', 'appearance', 'shortcuts', 'account']
.every(id => !context.isAdminManagedSettingsTab(id)),
);
check(
'Settings registry distinguishes admin-only visibility from admin-controlled routing',
['tools', 'users', 'system'].every(id => context.isAdminOnlySettingsTab(id))
&& ['services', 'added-models', 'integrations']
.every(id => !context.isAdminOnlySettingsTab(id)),
);
check(
'Settings registry provides search metadata without owning search UI',
context.getSettingsPanelSearchText('appearance').includes('theme')
&& context.getSettingsPanelSearchText('email').includes('smtp')
&& context.getSettingsPanelSearchText('missing') === '',
);
check(
'Settings registry exposes group membership in sidebar order',
context.getSettingsPanelsForGroup('communications')
.map(panel => panel.id)
.join(',') === 'integrations,email,reminders',
);
check(
'Settings registry matches every production-style tab and panel in the DOM fixture',
context.getSettingsRegistryIssues(fixture.modal).length === 0,
);
check(
'Settings search resolves metadata terms in registry order',
context.searchSettingsPanels('provider', { isAdmin: true })
.map(panel => panel.id)
.join(',') === 'services,added-models,search',
);
check(
'Settings search excludes admin-only panels for non-admin users',
context.searchSettingsPanels('agent tools', { isAdmin: false }).length === 0
&& context.searchSettingsPanels('agent tools', { isAdmin: true })
.map(panel => panel.id)
.join(',') === 'tools',
);
check(
'Settings search requires all query terms',
context.searchSettingsPanels('appearance theme', { isAdmin: false })
.map(panel => panel.id)
.join(',') === 'appearance',
);
let searchedPanel = null;
context.bindSettingsSearch(fixture.modal, {
isAdmin: () => false,
openPanel(tab) { searchedPanel = tab; },
});
fixture.searchInput.value = 'theme';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder renders matching production registry results',
!fixture.searchResults.classList.contains('hidden')
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1
&& fixture.searchResults.querySelector('[data-settings-search-result]').dataset.settingsSearchResult === 'appearance',
);
const finderOutsideTarget = document.createElement('div');
fixture.content.appendChild(finderOutsideTarget);
fixture.modal.dispatchEvent({
type: 'mousedown',
target: finderOutsideTarget,
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder click-away hides results while retaining the query',
fixture.searchInput.value === 'theme'
&& fixture.searchResults.classList.contains('hidden'),
);
fixture.searchInput.dispatchEvent({
type: 'focus',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder refocus restores results for an unchanged retained query',
fixture.searchInput.value === 'theme'
&& !fixture.searchResults.classList.contains('hidden')
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1,
);
fixture.searchInput.dispatchEvent({
type: 'keydown',
key: 'Enter',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder Enter opens the first result and resets the finder',
searchedPanel === 'appearance'
&& fixture.searchInput.value === ''
&& fixture.searchResults.classList.contains('hidden'),
);
searchedPanel = null;
fixture.searchInput.value = 'agent tools';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder does not expose admin-only results to non-admin users',
fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 0
&& fixture.searchResults.textContent !== '',
);
fixture.searchInput.value = 'theme';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
fixture.searchInput.dispatchEvent({
type: 'keydown',
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder Escape clears results without closing Settings',
fixture.searchInput.value === ''
&& fixture.searchResults.classList.contains('hidden'),
);
context.setSettingsSidebarWidth(fixture.modal, 280);
check(
'Settings sidebar width is clamped and applied through the production controller',
fixture.sidebar.style.values['--settings-sidebar-width'] === '280px',
);
const widthStorageKey = 'odysseus-settings-sidebar-width';
storage.clear();
const firstRunSidebar = buildFixture(document);
context.bindSettingsSidebar(firstRunSidebar.modal);
check(
'Settings sidebar first bind uses the declared 220px default when storage is empty',
firstRunSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
);
storage.clear();
storage.set(widthStorageKey, '276');
const storedSidebar = buildFixture(document);
context.bindSettingsSidebar(storedSidebar.modal);
check(
'Settings sidebar first bind restores a valid stored width',
storedSidebar.sidebar.style.values['--settings-sidebar-width'] === '276px',
);
storage.clear();
storage.set(widthStorageKey, 'not-a-number');
const malformedSidebar = buildFixture(document);
context.bindSettingsSidebar(malformedSidebar.modal);
check(
'Settings sidebar first bind falls back to default for malformed storage',
malformedSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
);
storage.clear();
storage.set(widthStorageKey, '10');
const minimumSidebar = buildFixture(document);
context.bindSettingsSidebar(minimumSidebar.modal);
storage.clear();
storage.set(widthStorageKey, '999');
const maximumSidebar = buildFixture(document);
context.bindSettingsSidebar(maximumSidebar.modal);
check(
'Settings sidebar first bind clamps stored widths to declared bounds',
minimumSidebar.sidebar.style.values['--settings-sidebar-width'] === '150px'
&& maximumSidebar.sidebar.style.values['--settings-sidebar-width'] === '340px',
);
context.setSettingsSidebarCollapsed(fixture.modal, true);
check(
'Settings sidebar collapse leaves the compact navigation rail state active',
fixture.sidebar.classList.contains('settings-sidebar-collapsed')
&& fixture.sidebarToggle.getAttribute('aria-label') === 'Expand settings navigation',
);
context.setSettingsSidebarCollapsed(fixture.modal, false, { width: 260 });
check(
'Settings sidebar expansion restores the requested expanded width',
!fixture.sidebar.classList.contains('settings-sidebar-collapsed')
&& fixture.sidebar.style.values['--settings-sidebar-width'] === '260px',
);
// Keyboard behavior must be exercised on a fixture that went through the
// real binding path; direct controller calls above intentionally do not bind
// event listeners.
storage.clear();
const keyboardSidebar = buildFixture(document);
context.bindSettingsSidebar(keyboardSidebar.modal);
context.setSettingsSidebarWidth(
keyboardSidebar.modal,
150,
{ persist: false },
);
keyboardSidebar.sidebarHandle.dispatchEvent({
type: 'keydown',
key: 'ArrowLeft',
preventDefault() {},
stopPropagation() {},
});
check(
'ArrowLeft at the sidebar minimum collapses the rail instead of getting stuck at 150px',
keyboardSidebar.sidebar.classList.contains('settings-sidebar-collapsed'),
);
context.setSettingsSidebarCollapsed(
keyboardSidebar.modal,
false,
{ width: 220, persist: false },
);
check(
'resizable Settings separator exposes its current ARIA range and value',
keyboardSidebar.sidebarHandle.getAttribute('aria-valuemin') === '150'
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuemax') === '340'
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuenow') === '220',
);
check('byId resolves elements through the production DOM helper', context.byId('settings-modal') === fixture.modal);
context.activateSettingsPanel(fixture.modal, 'appearance');
check(
'activateSettingsPanel switches sidebar and panel state together',
fixture.appearance.button.classList.contains('active')
&& !fixture.appearance.panel.classList.contains('hidden')
&& !fixture.services.button.classList.contains('active')
&& fixture.services.panel.classList.contains('hidden'),
);
check('getActiveSettingsTab reports the active panel', context.getActiveSettingsTab(fixture.modal) === 'appearance');
let activated = null;
let delegated = null;
context.bindSettingsNavigation(fixture.modal, {
openAdminTab(tab) { delegated = tab; return tab === 'system'; },
onPanelActivated(tab) { activated = tab; },
});
fixture.services.button.click();
check(
'normal navigation activates locally and notifies the coordinator',
activated === 'services' && fixture.services.button.classList.contains('active'),
);
activated = null;
fixture.system.button.click();
check(
'admin navigation delegates without performing a second local activation',
delegated === 'system' && activated === null && fixture.services.button.classList.contains('active'),
);
context.bindSettingsDrag(fixture.modal);
check(
'drag binding preserves the existing Settings drag contract',
dragCalls.length === 1
&& dragCalls[0][0] === fixture.modal
&& dragCalls[0][1].content === fixture.content
&& dragCalls[0][1].header === fixture.header
&& dragCalls[0][1].enableDock === true
&& dragCalls[0][1].skipSelector === 'button, input, select, .theme-opacity-wrap',
);
let disconnected = 0;
fixture.modal.classList.add('modal-left-docked');
fixture.content.style.setProperty('left', '123px');
fixture.content.dataset._tileZone = 'left';
fixture.content._leftDockNavObs = {
navObs: { disconnect() { disconnected += 1; } },
reanchor() {},
};
context.resetSettingsWindowPlacement(fixture.modal);
check(
'window placement reset clears docking observers and inline placement',
!fixture.modal.classList.contains('modal-left-docked')
&& dockCalls.some(call => call[0] === 'left' && call[1] === fixture.modal)
&& disconnected === 1
&& !('_tileZone' in fixture.content.dataset)
&& fixture.content.style.left === undefined
&& removedWindowListeners.some(call => call[0] === 'resize'),
);
fixture.modal.classList.add('modal-right-docked', 'hidden');
context.showSettingsModal(fixture.modal);
check(
'showSettingsModal restores a hidden modal before showing it',
!fixture.modal.classList.contains('hidden')
&& !fixture.modal.classList.contains('modal-right-docked')
&& dockCalls.some(call => call[0] === 'right'),
);
let closeCount = 0;
context.bindSettingsClose(fixture.modal, {
closeSettings() { closeCount += 1; },
isTouchInsideModal() { return false; },
});
const form = document.createElement('div');
form.id = 'unified-intg-form';
form.style.display = '';
form.appendChild(document.createElement('input'));
fixture.content.appendChild(form);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check(
'Escape closes an inner integration editor before closing Settings',
form.style.display === 'none' && form.children.length === 0 && closeCount === 0,
);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check('Escape closes Settings when no nested flow is active', closeCount === 1);
const popover = document.createElement('div');
popover.setAttribute('data-popover-open', '1');
popover.style.display = 'block';
fixture.content.appendChild(popover);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check('Escape leaves Settings open while a transient popover is active', closeCount === 1);
popover.classList.add('hidden');
context.hideSettingsModal(fixture.modal);
check(
'hideSettingsModal preserves the closing animation fallback semantics',
fixture.modal.classList.contains('hidden') && !fixture.content.classList.contains('modal-closing'),
);
console.log(JSON.stringify(results));
if (results.some(result => !result.pass)) process.exitCode = 1;
})();
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
import importlib.util
import os
import stat
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parent.parent
MIGRATION = ROOT / "scripts" / "migrate_searxng_settings.py"
COMPOSE_FILES = (
ROOT / "docker-compose.yml",
ROOT / "docker-compose.gpu-amd.yml",
ROOT / "docker-compose.gpu-nvidia.yml",
)
def _run(path: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(MIGRATION), str(path)],
capture_output=True,
check=False,
text=True,
)
def _load_migration_module():
spec = importlib.util.spec_from_file_location("searxng_settings_migration", MIGRATION)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_retained_settings_gain_defaults_without_changing_custom_content(tmp_path):
settings = tmp_path / "settings.yml"
retained = (
b"# retained deployment settings\n"
b"server:\n"
b' secret_key: "representative-retained-secret"\n'
b"search:\n"
b" safe_search: 1\n"
b" formats:\n"
b" - html\n"
b" - json\n"
b"ui:\n"
b" static_use_hash: true\n"
)
settings.write_bytes(retained)
settings.chmod(0o640)
before = settings.stat()
first = _run(settings)
assert first.returncode == 0, first.stderr
assert "representative-retained-secret" not in first.stdout + first.stderr
assert settings.read_bytes() == (
b"# retained deployment settings\n"
b"use_default_settings: true\n"
+ retained.removeprefix(b"# retained deployment settings\n")
)
after = settings.stat()
assert stat.S_IMODE(after.st_mode) == 0o640
assert (after.st_uid, after.st_gid) == (before.st_uid, before.st_gid)
migrated = settings.read_bytes()
second = _run(settings)
assert second.returncode == 0, second.stderr
assert settings.read_bytes() == migrated
assert second.stdout == ""
def test_fresh_generated_settings_are_left_byte_identical(tmp_path):
settings = tmp_path / "settings.yml"
generated = (ROOT / "config" / "searxng" / "settings.yml").read_bytes().replace(
b"__SEARXNG_SECRET__", b"representative-generated-secret"
)
settings.write_bytes(generated)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == generated
assert result.stdout == ""
@pytest.mark.parametrize(
"key",
(
b"use_default_settings: false\n",
b"use_default_settings:\n engines:\n keep_only:\n - brave\n",
b"'use_default_settings': true\n",
b'"use_default_settings": true\n',
),
)
def test_explicit_top_level_setting_is_not_overridden(tmp_path, key):
settings = tmp_path / "settings.yml"
original = key + b"server:\n secret_key: retained\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == original
def test_key_is_inserted_inside_explicit_yaml_document(tmp_path):
settings = tmp_path / "settings.yml"
original = b"\xef\xbb\xbf# header\r\n---\r\nserver:\r\n secret_key: retained\r\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == (
b"\xef\xbb\xbf# header\r\n---\r\nuse_default_settings: true\r\n"
b"server:\r\n secret_key: retained\r\n"
)
def test_indented_root_mapping_keeps_its_existing_indent(tmp_path):
settings = tmp_path / "settings.yml"
original = b" server:\n secret_key: retained\n search:\n safe_search: 1\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == b" use_default_settings: true\n" + original
@pytest.mark.parametrize(
"property_line",
(b"!!map\n", b"&settings\n", b"--- !!map\n"),
)
def test_block_mapping_properties_stay_attached_to_the_root(tmp_path, property_line):
settings = tmp_path / "settings.yml"
mapping = b"server:\n secret_key: retained\nsearch:\n safe_search: 1\n"
original = property_line + mapping
settings.write_bytes(original)
original_data = yaml.safe_load(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
expected = property_line + b"use_default_settings: true\n" + mapping
assert settings.read_bytes() == expected
migrated_data = yaml.safe_load(settings.read_bytes())
assert migrated_data.pop("use_default_settings") is True
assert migrated_data == original_data
def test_flow_mapping_gains_default_inheritance_without_reformatting(tmp_path):
settings = tmp_path / "settings.yml"
original = b"{server: {secret_key: retained}, search: {safe_search: 1}}\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == (
b"{use_default_settings: true, " + original.removeprefix(b"{")
)
@pytest.mark.parametrize(
("original", "expected"),
(
(b"{}\n", b"{use_default_settings: true}\n"),
(
b"{server: {use_default_settings: false, secret_key: retained}}\n",
b"{use_default_settings: true, "
b"server: {use_default_settings: false, secret_key: retained}}\n",
),
(
b"!!map {server: {secret_key: retained}}\n",
b"!!map {use_default_settings: true, server: {secret_key: retained}}\n",
),
),
)
def test_other_flow_mapping_shapes_gain_only_the_root_key(tmp_path, original, expected):
settings = tmp_path / "settings.yml"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == expected
@pytest.mark.parametrize(
"original",
(
b"{use_default_settings: true, server: {secret_key: retained}}\n",
b'{"use_default_settings": {engines: {keep_only: [brave]}}}\n',
b"{use_default_settings: true, server: {secret_key: abc#def}}\n",
b"{use_default_settings: true, server: {secret_key: 'ab''cd'}}\n",
),
)
def test_flow_mapping_with_existing_setting_is_left_untouched(tmp_path, original):
settings = tmp_path / "settings.yml"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == original
@pytest.mark.parametrize(
"original",
(
b"%YAML 1.1\n---\nuse_default_settings: true\n"
b"server:\n secret_key: retained\n",
b"use_default_settings: true\nserver:\n secret_key: retained\n...\n",
),
)
def test_valid_document_metadata_with_existing_key_is_left_untouched(
tmp_path, original
):
settings = tmp_path / "settings.yml"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == original
def test_invalid_utf8_is_not_replaced(tmp_path):
settings = tmp_path / "settings.yml"
original = b"server:\n secret_key: \xff\n"
settings.write_bytes(original)
before = os.stat(settings)
result = _run(settings)
after = os.stat(settings)
assert result.returncode == 1
assert settings.read_bytes() == original
assert after.st_ino == before.st_ino
def test_temporary_file_is_chmodded_before_it_is_chowned(tmp_path, monkeypatch):
# The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/
# DAC_OVERRIDE and carries no FOWNER, and searxng's entrypoint chowns
# /etc/searxng to searxng:searxng, so every retained settings file is owned
# by that user. Chowning the temporary file first therefore makes the chmod
# that follows fail with EPERM, and `set -eu` in the Compose entrypoint
# turns that into a container that never starts. The guard below refuses
# the chmod once the chown has landed, the way the kernel does.
migration = _load_migration_module()
settings = tmp_path / "settings.yml"
settings.write_bytes(b"server:\n secret_key: retained\n")
settings.chmod(0o640)
calls = []
real_fchmod = migration.os.fchmod
real_fchown = migration.os.fchown
def guarded_fchmod(fd, mode):
if "fchown" in calls:
raise PermissionError(1, "Operation not permitted")
calls.append("fchmod")
return real_fchmod(fd, mode)
def recording_fchown(fd, uid, gid):
calls.append("fchown")
return real_fchown(fd, uid, gid)
monkeypatch.setattr(migration.os, "fchmod", guarded_fchmod)
monkeypatch.setattr(migration.os, "fchown", recording_fchown)
assert migration.migrate_settings(settings) is True
assert calls == ["fchmod", "fchown"]
assert stat.S_IMODE(settings.stat().st_mode) == 0o640
assert settings.read_bytes() == (
b"use_default_settings: true\nserver:\n secret_key: retained\n"
)
def test_replace_failure_preserves_original_and_removes_temporary_file(
tmp_path, monkeypatch
):
migration = _load_migration_module()
settings = tmp_path / "settings.yml"
original = b"server:\n secret_key: retained\n"
settings.write_bytes(original)
before = settings.stat()
def fail_replace(_source, _destination):
raise OSError("injected replace failure")
monkeypatch.setattr(migration.os, "replace", fail_replace)
with pytest.raises(OSError, match="injected replace failure"):
migration.migrate_settings(settings)
after = settings.stat()
assert settings.read_bytes() == original
assert after.st_ino == before.st_ino
assert list(tmp_path.iterdir()) == [settings]
@pytest.mark.parametrize("compose_file", COMPOSE_FILES, ids=lambda path: path.name)
def test_compose_runs_migration_for_all_variants(compose_file):
text = compose_file.read_text(encoding="utf-8")
# The `|| true` is load-bearing: the entrypoint runs under `set -eu`, so
# without it a settings file the migration cannot parse or rewrite stops
# searxng from starting at all instead of merely going unmigrated.
assert (
"/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py "
"/etc/searxng/settings.yml || true" in text
)
assert (
"./scripts/migrate_searxng_settings.py:"
"/tmp/migrate-searxng-settings.py:ro,z" in text
)
+117
View File
@@ -0,0 +1,117 @@
"""Behavioral coverage for the modular Settings shell.
The leaf harness provides focused assertions around the extracted navigation
and lifecycle primitives. The coordinator smoke separately loads the real
settings.js module through Node's native ESM linker together with the real
Settings shell modules, covering their import/export contract and the public
open/close wiring.
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_LEAF_HELPER = _REPO / "tests" / "helpers" / "test_settings_shell.js"
_COORDINATOR_HELPER = (
_REPO / "tests" / "helpers" / "test_settings_shell_coordinator.mjs"
)
_HAS_NODE = shutil.which("node") is not None
_STYLE = _REPO / "static" / "style.css"
def test_settings_desktop_width_targets_settings_not_cookbook():
source = _STYLE.read_text(encoding="utf-8")
settings_rule = re.search(
r"(?ms)^\.settings-modal-content\s*\{[^}]*"
r"width:\s*min\(1040px,\s*94vw\);[^}]*\}",
source,
)
cookbook_rule = re.search(
r"(?ms)^\.cookbook-edit-modal\s*\{[^}]*"
r"width:\s*min\(720px,\s*92vw\);[^}]*\}",
source,
)
cookbook_wrong_width = re.search(
r"(?ms)^\.cookbook-edit-modal\s*\{[^}]*"
r"width:\s*min\(1040px,\s*94vw\);[^}]*\}",
source,
)
assert settings_rule is not None, (
"The standalone .settings-modal-content rule must carry the "
"1040px/94vw desktop width"
)
assert cookbook_rule is not None, (
"The standalone .cookbook-edit-modal rule must retain its "
"720px/92vw width"
)
assert cookbook_wrong_width is None, (
"The Settings desktop width must not leak into Cookbook"
)
def _run_node(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["node", *args],
cwd=str(_REPO),
capture_output=True,
text=True,
encoding="utf-8",
timeout=30,
)
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_settings_shell_leaf_behavior():
proc = _run_node(str(_LEAF_HELPER))
assert proc.returncode == 0, (
f"Node execution error:\nSTDERR:\n{proc.stderr}\nSTDOUT:\n{proc.stdout}"
)
results = json.loads(proc.stdout.strip())
assert results, "Settings shell leaf harness returned no assertions"
for result in results:
assert result["pass"] is True, (
f"Failed JS behavioral test: {result['test']}"
+ (
f" ({result.get('detail')})"
if result.get("detail")
else ""
)
)
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_settings_shell_real_esm_coordinator():
proc = _run_node(
"--experimental-vm-modules",
str(_COORDINATOR_HELPER),
)
assert proc.returncode == 0, (
"Real Settings coordinator smoke failed:\n"
f"STDERR:\n{proc.stderr}\n"
f"STDOUT:\n{proc.stdout}"
)
result = json.loads(proc.stdout.strip())
assert result == {
"realEsmGraph": True,
"initialization": True,
"finderBinding": True,
"sidebarBinding": True,
"navigationCallback": True,
"directOpen": True,
"directClose": True,
}