Theseus: shield green polish, picker→Search section, toggle-vs-remove

Three follow-up asks from the previous ship:

1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the
   GitHub-style saturated green, matches the +N/-N diff colour the user
   pointed at as reference.

2. Engine-picker "Search settings…" now opens the Search section
   directly instead of General. New IPC channel `focus-section` fires
   from main after picker-open-settings, carried through
   settings-preload as `onFocusSection`, and the settings.html sidebar
   handler exposes showSection(sec) so any section can be focused
   programmatically. Works for both a fresh settings tab (fires on
   did-finish-load) and an already-open one (fires immediately).

3. Toggle no longer removes an engine from the list. Two-tier state:
   INSTALLED (visible in the Settings list) and ENABLED (toggled on in
   the toolbar dropdown). Toggling off keeps the row visible with an
   .off class (dimmed 55%). Right-click any row → new context menu with
   "Remove from list" is what actually removes an engine (built-ins go
   back to the catalog, customs are dropped entirely).

Model changes:
- New settings.installedEngines persistent array (defaults to
  DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines.
- isInstalled(id) helper; allEngines() carries `installed: bool` alongside
  `enabled`.
- New IPC `remove-from-list` (right-click action); exposed as
  removeFromList in settings-preload.
- set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add"
  flow), preserves installed state when disabling.
- add-engine (custom URL) auto-adds the new id to enabledEngines too.
- remove-engine (custom delete) prunes from enabledEngines as well.
- Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"]
  if everything gets removed.

Settings UI:
- Enabled list shows all INSTALLED engines (was: only enabled), rendered
  with toggle reflecting enabled state; rows carry data-builtin so the
  context menu picks the right remove IPC.
- Catalog panel and Discover-more pane filter on !installed instead of
  !enabled — a toggled-off engine stays in the enabled list, not here.
- Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape.
- .eng.off dims the row and mutes the name colour.

Preview harness stubs updated to include the `installed` field on every
engine + `removeFromList` and `onFocusSection` no-op stubs so
_settings-preview.html renders the new UI accurately.
This commit is contained in:
Local Dev 2026-08-06 01:41:31 +02:00
parent 3803202df6
commit 1d2faf64f3
4 changed files with 153 additions and 35 deletions

View file

@ -67,7 +67,7 @@
background: transparent; cursor: pointer; padding: 0; color: var(--dim); } background: transparent; cursor: pointer; padding: 0; color: var(--dim); }
.secbadge:hover { background: var(--line2); } .secbadge:hover { background: var(--line2); }
.secbadge svg { width: 16px; height: 16px; fill: currentColor; } .secbadge svg { width: 16px; height: 16px; fill: currentColor; }
.secbadge.secure { color: #4fd1a5; } .secbadge.secure { color: #3fb950; } /* GitHub-style saturated green */
.secbadge.insecure { color: #f6768a; } .secbadge.insecure { color: #f6768a; }
.secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */ .secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */
input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; } input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; }

84
main.js
View file

@ -70,13 +70,19 @@ const DEFAULT_ENABLED = ["duckduckgo", "google", "brave", "bing", "startpage"];
const faviconUrl = (domain) => (domain ? `https://icons.duckduckgo.com/ip3/${domain}.ico` : null); const faviconUrl = (domain) => (domain ? `https://icons.duckduckgo.com/ip3/${domain}.ico` : null);
function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } } function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } }
function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); } function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); }
// Built-in + user-added, as flat metadata for the pickers. Built-in obey // Two-tier state: an engine is INSTALLED if it's in the user's Additional
// enabledEngines; custom engines are always enabled. // list (visible in Settings), and ENABLED if it's currently toggled on
// (visible in the toolbar dropdown). Toggle flips enabled only; right-click
// "Remove from list" is what actually removes an installed engine.
function isInstalled(id) {
if ((settings.customEngines || []).some((e) => e.id === id)) return true; // customs are always installed
return (settings.installedEngines || DEFAULT_ENABLED).includes(id);
}
function allEngines() { function allEngines() {
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) => const list = Object.entries(SEARCH_ENGINES).map(([id, e]) =>
({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, enabled: isEnabled(id) })); ({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) }));
for (const c of settings.customEngines || []) for (const c of settings.customEngines || [])
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", tier: "custom", builtin: false, enabled: true }); list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", tier: "custom", builtin: false, installed: true, enabled: isEnabled(c.id) });
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort). // Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
const order = settings.engineOrder || []; const order = settings.engineOrder || [];
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
@ -157,7 +163,8 @@ const SETTINGS_DEFAULTS = {
languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text
locationMode: "hide", locationRegion: "europe", locationLat: "40.7128", locationLon: "-74.0060", // spoof by region, or manual coords locationMode: "hide", locationRegion: "europe", locationLat: "40.7128", locationLon: "-74.0060", // spoof by region, or manual coords
searchEngine: "duckduckgo",// default search engine (built-in id or a custom id) searchEngine: "duckduckgo",// default search engine (built-in id or a custom id)
enabledEngines: DEFAULT_ENABLED.slice(), // which built-in engines show in the dropdown installedEngines: DEFAULT_ENABLED.slice(), // built-in engines added to the user's list (visible in Settings)
enabledEngines: DEFAULT_ENABLED.slice(), // subset that's currently toggled on (shown in the toolbar dropdown)
engineOrder: [], // user-defined display order of engine ids (empty = natural) engineOrder: [], // user-defined display order of engine ids (empty = natural)
customEngines: [], // user-added: [{ id, name, url-with-%s }] customEngines: [], // user-added: [{ id, name, url-with-%s }]
theme: "dark", // dark | light | system — drives prefers-color-scheme in all views theme: "dark", // dark | light | system — drives prefers-color-scheme in all views
@ -761,6 +768,19 @@ function emitTabs() {
}); });
} }
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } } function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
// Pull the tab's real URL from webContents after Electron navigates, so in-page
// clicks (subpages of a BCNR site, subdomain hops, cross-origin redirects) update
// the address bar. Without this, t.url is only refreshed on programmatic loads —
// navigateTab / the collision switcher — and everything else sticks on the parent.
// Internal bns:// → https:// for display, matching navigateTab's convention that
// https:// is what the user sees regardless of how the bytes were fetched.
function refreshTabUrl(tab) {
if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = ""
try {
const raw = tab.view.webContents.getURL();
if (raw && !raw.startsWith("file:")) tab.url = raw.replace(/^bns:\/\//, "https://");
} catch {}
}
function loadHome(id) { function loadHome(id) {
const t = tabById(id); if (!t) return; const t = tabById(id); if (!t) return;
t.url = ""; t.title = "Theseus"; t.prov = { host: "", kind: "home" }; t.url = ""; t.title = "Theseus"; t.prov = { host: "", kind: "home" };
@ -779,8 +799,8 @@ function createTab(initial, opts = {}) {
tabs.push(tab); tabs.push(tab);
win.contentView.addChildView(view); win.contentView.addChildView(view);
wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); }); wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); });
wc.on("did-navigate", () => emitTabs()); wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); });
wc.on("did-navigate-in-page", () => emitTabs()); wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); });
wc.on("did-start-loading", () => setLoading(tab, true)); wc.on("did-start-loading", () => setLoading(tab, true));
wc.on("did-stop-loading", () => setLoading(tab, false)); wc.on("did-stop-loading", () => setLoading(tab, false));
wc.on("will-navigate", (e, u) => { wc.on("will-navigate", (e, u) => {
@ -1092,28 +1112,58 @@ ipcMain.handle("set-search-engine", (_e, id) => {
return settings.searchEngine; return settings.searchEngine;
}); });
ipcMain.handle("add-engine", (_e, eng) => { ipcMain.handle("add-engine", (_e, eng) => {
// A custom engine needs a name and a URL template containing "%s". // A custom engine needs a name and a URL template containing "%s". Adding
// installs it in both the settings list AND the toolbar dropdown.
if (eng && eng.name && eng.url && String(eng.url).includes("%s")) { if (eng && eng.name && eng.url && String(eng.url).includes("%s")) {
const id = "custom-" + Date.now().toString(36); const id = "custom-" + Date.now().toString(36);
settings.customEngines = [...(settings.customEngines || []), settings.customEngines = [...(settings.customEngines || []),
{ id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || "🔍").slice(0, 4), url: String(eng.url).slice(0, 400) }]; { id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || "🔍").slice(0, 4), url: String(eng.url).slice(0, 400) }];
// Custom engines are auto-installed and enabled.
settings.enabledEngines = [...new Set([...(settings.enabledEngines || DEFAULT_ENABLED), id])];
settings.searchEngine = id; // select the one just added settings.searchEngine = id; // select the one just added
saveSettings(); emitEngines(); saveSettings(); emitEngines();
} }
return { engines: allEngines(), current: settings.searchEngine }; return { engines: allEngines(), current: settings.searchEngine };
}); });
ipcMain.handle("remove-engine", (_e, id) => { ipcMain.handle("remove-engine", (_e, id) => {
// Drop a custom engine entirely — from customEngines and any list that
// referenced it.
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id); settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo"; if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines(); saveSettings(); emitEngines();
return { engines: allEngines(), current: settings.searchEngine }; return { engines: allEngines(), current: settings.searchEngine };
}); });
// Enable/disable a built-in engine (which show in the toolbar dropdown). // Right-click "Remove from list": drops a built-in from installedEngines AND
// enabledEngines so it goes back to the catalog. For custom engines this
// aliases to remove-engine (they don't live in installedEngines).
ipcMain.handle("remove-from-list", (_e, id) => {
if ((settings.customEngines || []).some((e) => e.id === id)) {
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
} else if (SEARCH_ENGINES[id]) {
settings.installedEngines = (settings.installedEngines || DEFAULT_ENABLED).filter((x) => x !== id);
} else {
return { engines: allEngines(), current: settings.searchEngine };
}
settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (settings.enabledEngines.length === 0) settings.enabledEngines = ["duckduckgo"]; // never empty
if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
return { engines: allEngines(), current: settings.searchEngine };
});
// Enable/disable a built-in engine. Enabling from the catalog also INSTALLS it
// (adds to installedEngines). Disabling only removes it from enabledEngines —
// it stays in installedEngines so the row remains visible with the toggle off.
ipcMain.handle("set-engine-enabled", (_e, id, on) => { ipcMain.handle("set-engine-enabled", (_e, id, on) => {
if (SEARCH_ENGINES[id]) { if (SEARCH_ENGINES[id]) {
let list = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id); let installed = (settings.installedEngines || DEFAULT_ENABLED).slice();
if (on) list.push(id); let enabled = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
settings.enabledEngines = list.length ? list : ["duckduckgo"]; // never empty if (on) {
if (!installed.includes(id)) installed.push(id);
enabled.push(id);
}
settings.installedEngines = installed;
settings.enabledEngines = enabled.length ? enabled : ["duckduckgo"]; // never empty
if (!enabledEnginesList().some((e) => e.id === settings.searchEngine)) if (!enabledEnginesList().some((e) => e.id === settings.searchEngine))
settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo"; settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines(); saveSettings(); emitEngines();
@ -1136,7 +1186,15 @@ ipcMain.handle("pick-engine", (_e, id) => {
if (enabledEnginesList().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); } if (enabledEnginesList().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
showEnginePicker(false); showEnginePicker(false);
}); });
ipcMain.handle("picker-open-settings", () => { showEnginePicker(false); const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); }); ipcMain.handle("picker-open-settings", () => {
showEnginePicker(false);
const focus = (t) => { try { t.view.webContents.send("focus-section", "search"); } catch {} };
const ex = tabs.find((t) => t.settings);
if (ex) { setActive(ex.id); focus(ex); return; }
const id = createTab(null, { settings: true });
const t = tabById(id);
if (t) t.view.webContents.once("did-finish-load", () => focus(t));
});
// ---- Downloads -------------------------------------------------------------- // ---- Downloads --------------------------------------------------------------
ipcMain.handle("downloads-get", () => downloadsPublic()); ipcMain.handle("downloads-get", () => downloadsPublic());
ipcMain.handle("toggle-downloads", (_e, rect) => { ipcMain.handle("toggle-downloads", (_e, rect) => {

View file

@ -7,6 +7,10 @@ contextBridge.exposeInMainWorld("cfg", {
removeEngine: (id) => ipcRenderer.invoke("remove-engine", id), removeEngine: (id) => ipcRenderer.invoke("remove-engine", id),
setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on), setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on),
setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids), setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids),
removeFromList: (id) => ipcRenderer.invoke("remove-from-list", id),
// Main asks settings to jump to a specific sidebar section (e.g. from the
// engine picker's "Search settings…" click). Emits the section id string.
onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)),
// Collision-mode: BCNR/ICANN policy + per-name/per-TLD overrides // Collision-mode: BCNR/ICANN policy + per-name/per-TLD overrides
collisionState: () => ipcRenderer.invoke("collision-state"), collisionState: () => ipcRenderer.invoke("collision-state"),
setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p), setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p),

View file

@ -89,8 +89,21 @@
.eng .grip:active{cursor:grabbing} .eng .grip:active{cursor:grabbing}
.eng.dragging{opacity:.45} .eng.dragging{opacity:.45}
.eng.over{border-color:var(--acid);box-shadow:0 -2px 0 var(--acid) inset} .eng.over{border-color:var(--acid);box-shadow:0 -2px 0 var(--acid) inset}
.eng.off{opacity:.55}
.eng.off .enm{color:var(--mut)}
.ehdr{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px;padding-top:2px} .ehdr{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px;padding-top:2px}
.ehdr:first-child{margin-top:0} .ehdr:first-child{margin-top:0}
/* right-click context menu for an engine row */
.ctxmenu{position:fixed;z-index:9999;background:#1c222c;border:1px solid var(--line);border-radius:8px;
box-shadow:0 12px 34px #000c;padding:4px;min-width:180px;font-size:13px;color:var(--ink)}
.ctxmenu .mi{padding:7px 12px;border-radius:5px;cursor:pointer;white-space:nowrap}
.ctxmenu .mi:hover{background:#ffffff10}
.ctxmenu .mi.danger{color:#f6768a}
.ctxmenu .mi.danger:hover{background:rgba(246,118,138,.12)}
@media (prefers-color-scheme: light){
.ctxmenu{background:#ffffff;border-color:rgba(0,0,0,.15)}
.ctxmenu .mi:hover{background:rgba(0,0,0,.05)}
}
/* engine catalog panel — appears under the enabled list when "+ Add" is clicked */ /* engine catalog panel — appears under the enabled list when "+ Add" is clicked */
.engcat{margin-top:6px;padding:12px 12px 10px;background:#0e131c;border:1px solid var(--line);border-radius:10px} .engcat{margin-top:6px;padding:12px 12px 10px;background:#0e131c;border:1px solid var(--line);border-radius:10px}
.engcat .cat{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;font-size:13px} .engcat .cat{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;font-size:13px}
@ -326,10 +339,15 @@
const C = window.cfg; const C = window.cfg;
// sidebar navigation // sidebar navigation
const sections = ["general", "search", "naming", "performance", "privacy"]; const sections = ["general", "search", "naming", "performance", "privacy"];
document.querySelectorAll(".side a").forEach((a) => a.onclick = () => { function showSection(sec) {
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x === a)); if (!sections.includes(sec)) return;
for (const s of sections) document.getElementById(s).hidden = (s !== a.dataset.sec); document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec));
}); for (const s of sections) document.getElementById(s).hidden = (s !== sec);
}
document.querySelectorAll(".side a").forEach((a) => a.onclick = () => showSection(a.dataset.sec));
// Main-process asks us to jump to a section (e.g. picker's "Search settings…"
// click routes to the Search section instead of the General default).
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices"]; const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices"];
C.get().then((s) => { C.get().then((s) => {
@ -358,24 +376,25 @@
return opts ? `<optgroup label="${label}">${opts}</optgroup>` : ""; return opts ? `<optgroup label="${label}">${opts}</optgroup>` : "";
}).join(""); }).join("");
sel.value = d.current; sel.value = d.current;
// Main list = the engines the user has enabled (i.e. actually shown in the // Main list = engines the user has INSTALLED. The toggle only flips
// toolbar dropdown). Everything else lives in the catalog panel below and // enabled/disabled — the row STAYS. Right-click a row → "Remove from
// is added via "+ Add". Drag reorders within a kind; toggling off moves an // list" is what actually removes an engine (back to the catalog for
// engine back to the catalog; custom engines get a ✕ to delete entirely. // built-ins, permanently for customs).
const list = document.getElementById("engineList"); const list = document.getElementById("engineList");
const rowFor = (e) => `<div class="eng" data-id="${e.id}" data-kind="${e.kind || "search"}" draggable="true">` + const installed = d.engines.filter((e) => e.installed);
const rowFor = (e) => `<div class="eng${e.enabled ? "" : " off"}" data-id="${e.id}" data-kind="${e.kind || "search"}" data-builtin="${e.builtin ? 1 : 0}" draggable="true">` +
`<span class="grip" title="Drag to reorder"></span>` + `<span class="grip" title="Drag to reorder"></span>` +
`<span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>` + `<span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>` +
(e.builtin `<label class="sw sm" title="${e.enabled ? "Turn off" : "Turn on"}"><input type="checkbox" data-id="${e.id}" ${e.enabled ? "checked" : ""}><span class="track"><span class="knob"></span></span></label>` +
? `<label class="sw sm"><input type="checkbox" data-id="${e.id}" ${e.enabled ? "checked" : ""}><span class="track"><span class="knob"></span></span></label>` `</div>`;
: `<button class="cx" data-id="${e.id}" title="Remove"></button>`) + `</div>`;
list.innerHTML = ENGINE_KINDS.map(({ key, label }) => { list.innerHTML = ENGINE_KINDS.map(({ key, label }) => {
const rows = enabled.filter((e) => (e.kind || "search") === key).map(rowFor).join(""); const rows = installed.filter((e) => (e.kind || "search") === key).map(rowFor).join("");
if (!rows) return ""; if (!rows) return "";
return `<div class="ehdr">${label}</div>${rows}`; return `<div class="ehdr">${label}</div>${rows}`;
}).join(""); }).join("");
// Two catalog panes, split by tier: // Two catalog panes, split by tier — filtered by !installed now, not
// catalog — curated first-class built-ins the user hasn't enabled // !enabled (a toggled-off engine stays in the enabled list, not here):
// catalog — curated first-class built-ins the user hasn't installed
// extra — wider bank, filtered live by the "Discover more" search box // extra — wider bank, filtered live by the "Discover more" search box
const cat = document.getElementById("catalogList"); const cat = document.getElementById("catalogList");
const extra = document.getElementById("extraList"); const extra = document.getElementById("extraList");
@ -385,13 +404,13 @@
`<span class="enm">${esc(e.name)}</span>` + `<span class="enm">${esc(e.name)}</span>` +
`<span class="kind">${(e.kind || "search") === "llm" ? "AI" : "Search"}</span>` + `<span class="kind">${(e.kind || "search") === "llm" ? "AI" : "Search"}</span>` +
`<button class="add" data-add="${e.id}">+ Add</button></div>`; `<button class="add" data-add="${e.id}">+ Add</button></div>`;
const off = d.engines.filter((e) => e.builtin && !e.enabled); const uninstalled = d.engines.filter((e) => e.builtin && !e.installed);
const catalogOff = off.filter((e) => (e.tier || "catalog") === "catalog"); const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog");
const extraOff = off.filter((e) => e.tier === "extra"); const extraOff = uninstalled.filter((e) => e.tier === "extra");
if (cat) { if (cat) {
cat.innerHTML = catalogOff.length cat.innerHTML = catalogOff.length
? catalogOff.map(catRow).join("") ? catalogOff.map(catRow).join("")
: `<div class="cempty2">All curated engines are enabled. Discover more below or add a custom URL.</div>`; : `<div class="cempty2">All curated engines are already in your list. Discover more below or add a custom URL.</div>`;
cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines)); cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
} }
if (extra) { if (extra) {
@ -400,7 +419,7 @@
const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff; const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff;
extra.innerHTML = shown.length extra.innerHTML = shown.length
? shown.map(catRow).join("") ? shown.map(catRow).join("")
: `<div class="cempty2">${filt ? "No engines match that filter." : "All discoverable engines are enabled."}</div>`; : `<div class="cempty2">${filt ? "No engines match that filter." : "All discoverable engines are already in your list."}</div>`;
extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines)); extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
}; };
paintExtras(filterInput ? filterInput.value : ""); paintExtras(filterInput ? filterInput.value : "");
@ -409,8 +428,16 @@
filterInput.addEventListener("input", () => paintExtras(filterInput.value)); filterInput.addEventListener("input", () => paintExtras(filterInput.value));
} }
} }
// Toggle: pure on/off in the enabled set — the row stays visible either way.
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines)); list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
list.querySelectorAll(".cx").forEach((b) => b.onclick = () => C.removeEngine(b.dataset.id).then(renderEngines)); // Right-click any row → context menu with "Remove from list" (moves a
// built-in back to the catalog; deletes a custom entirely).
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
openEngineMenu(row, e.clientX, e.clientY);
});
});
// drag-and-drop reorder — same-kind only (dropping a Search engine into // drag-and-drop reorder — same-kind only (dropping a Search engine into
// the LLM section would just re-group visually on next render, so we // the LLM section would just re-group visually on next render, so we
// reject cross-kind drags outright). // reject cross-kind drags outright).
@ -436,6 +463,35 @@
}); });
}); });
} }
// Floating right-click menu for an engine row. Only one open at a time.
let ctxOpen = null;
function closeEngineMenu() { if (ctxOpen) { ctxOpen.remove(); ctxOpen = null; } }
function openEngineMenu(row, x, y) {
closeEngineMenu();
const id = row.dataset.id;
const builtin = row.dataset.builtin === "1";
const m = document.createElement("div");
m.className = "ctxmenu";
m.innerHTML = `<div class="mi danger" data-act="remove">Remove from list</div>`;
document.body.appendChild(m);
// Position, keeping the menu inside the viewport.
const rect = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
m.style.left = Math.min(x, vw - rect.width - 6) + "px";
m.style.top = Math.min(y, vh - rect.height - 6) + "px";
m.querySelector('[data-act="remove"]').onclick = () => {
closeEngineMenu();
const call = builtin ? C.removeFromList(id) : C.removeEngine(id);
call.then(renderEngines);
};
ctxOpen = m;
setTimeout(() => {
const off = (ev) => { if (!m.contains(ev.target)) { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
const esc = (ev) => { if (ev.key === "Escape") { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", esc);
}, 0);
}
sel.onchange = () => C.set("searchEngine", sel.value); sel.onchange = () => C.set("searchEngine", sel.value);
// appearance (theme) — three visual cards: system | light | dark. Any // appearance (theme) — three visual cards: system | light | dark. Any
// unrecognised saved value falls back to "system" (follow the OS). // unrecognised saved value falls back to "system" (follow the OS).