Theseus: download tracker, search split, discover-more tier, UX polish
Bundle of UX + feature work. Split from packaging by intent so the diff
is reviewable; the next Theseus rebuild ships it.
Features
- Download tracker (new): session.on("will-download") → per-item state
{id, filename, url, mime, total, received, state, savePath, startedAt}
with updated/done event handlers. New downloadsPop WebContentsView
loads downloads.html (new file) + downloads-preload.js (new file);
panel positioned under a new #downloads toolbar button between search
and Tor. Full IPC: downloads-get, toggle/close/resize-downloads,
download-open/show/cancel/clear, downloads-clear-all. In-memory only —
cross-session persistence is a future addition. Button badge shows
active count + spin/done/err color.
- Search engines split by kind + tier:
* kind: "search" | "llm" — separate headers in picker + settings
("Search with" / "Ask an AI"). Empty sections hidden.
* tier: "catalog" | "extra" — Settings now has THREE panes behind the
"+ Add search engine" button: curated catalog, wider discoverable
bank filtered by a live search input, custom URL form.
* DEFAULT_ENABLED unchanged (5 major engines).
* Custom user-added engines carry tier="custom" (never in catalog/extra
panes).
- 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract,
Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule
as LLMs: if one starts bouncing to a login gate, drop it.
Bug fixes
- Loadbar collapses to 0px when idle (was reserving a permanent 2px
strip below the address bar). .loadbar {height:0} + .loadbar.on
{height:2px} + 120ms transition.
- Native <select> popup theme sync via :root { color-scheme: dark } +
@media(prefers-color-scheme: light). nativeTheme.themeSource already
drives prefers-color-scheme, so the OS popup color follows the app
theme automatically (fixed light popup on dark app / vice versa).
- .ctl layout flipped to flex-direction: row with flex-wrap so
anti-fingerprint mode + value fields fit side-by-side.
Settings restructure
- General section: Startup group at the top ("Open previous windows and
tabs" toggle), then Appearance below with three visual THEME CARDS
(System / Light / Dark) — small mock-browser previews per theme,
Firefox-style, active card gets a blue ring. System pipes through to
nativeTheme.themeSource = "system".
- Search promoted to a top-level sidebar item between General and
Naming. Search-engine controls moved out of General into Search.
- Search section: enabled list shows only enabled engines, grouped by
kind, drag-reorder within a kind. "+ Add search engine" opens the
catalog/extras/custom-URL panel.
Search engine catalog trims (already flagged in prior work)
- Removed ChatGPT / Claude / You.com (login-gated ?q=).
- Removed SearXNG (federated; every single-instance default rots).
Docs
- TheseusNavigator/PENDING.md and GOTCHAS.md born with this work
(see the HANDOFF.md commit for the convention).
- PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be
emptied after this ship lands.
Preview harness
- _preview.html + _settings-preview.html stubs updated with kind + tier
+ downloads seed + tier="extra" samples so the preview reflects reality.
Both files are gitignored — local only.
Coordination
- Parallel session's collision-policy work (chrome.html registry chips,
popover switcher, Naming section, in-tab collision prompt) already
landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds
cleanly on top.
This commit is contained in:
parent
c3f771ac1c
commit
c2ba26877f
7 changed files with 475 additions and 39 deletions
121
GOTCHAS.md
Normal file
121
GOTCHAS.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Theseus gotchas
|
||||||
|
|
||||||
|
Non-obvious things sessions keep re-deriving. If you found this file because
|
||||||
|
something broke in a way that felt spooky, the answer is probably here.
|
||||||
|
|
||||||
|
## `build.files` in [package.json](package.json) is EXPLICIT
|
||||||
|
|
||||||
|
electron-builder ships **only what's listed** in `build.files`. Every runtime-
|
||||||
|
loaded HTML/preload/module MUST be there. A missing entry causes the
|
||||||
|
`loadFile("foo.html")` in a WebContentsView to silently open blank in the
|
||||||
|
packaged app — visually indistinguishable from "the button does nothing".
|
||||||
|
|
||||||
|
`npm start` (dev) never catches this because it reads from the source tree
|
||||||
|
directly. Only a real `npm run dist` + install exposes it.
|
||||||
|
|
||||||
|
Every time you add a new `WebContentsView` or `loadFile`, add its HTML and
|
||||||
|
preload to the `files` array. Sanity check after building:
|
||||||
|
|
||||||
|
```
|
||||||
|
node -e "const b = require('fs').readFileSync('dist-public/win-unpacked/resources/app.asar'); for (const n of ['engine-picker.html','popover.html','downloads.html','engine-picker-preload.js','popover-preload.js','downloads-preload.js']) console.log(n, b.indexOf(Buffer.from(n)) >= 0 ? 'OK' : 'MISSING');"
|
||||||
|
```
|
||||||
|
|
||||||
|
This has bit us once (search-picker dropdown, shipped `6bbccf9c`, blank
|
||||||
|
picker). Do not let it bite twice.
|
||||||
|
|
||||||
|
## Building — admin terminal the first time
|
||||||
|
|
||||||
|
`npm run dist` needs an **admin** terminal on the machine's first Theseus
|
||||||
|
build so `electron-builder`'s `winCodeSign` package can extract its
|
||||||
|
`darwin/` symlinks (requires `SeCreateSymbolicLink`). Subsequent builds
|
||||||
|
reuse the cache under `$LOCALAPPDATA/electron-builder/Cache/winCodeSign/`
|
||||||
|
and don't need admin.
|
||||||
|
|
||||||
|
If DNS is misbehaving on the machine, pin these hosts in
|
||||||
|
`C:\Windows\System32\drivers\etc\hosts` before building so electron-builder
|
||||||
|
can fetch: `github.com`, `codeload.github.com`,
|
||||||
|
`objects.githubusercontent.com`, `release-assets.githubusercontent.com`.
|
||||||
|
Strip them after.
|
||||||
|
|
||||||
|
## Reproducible builds
|
||||||
|
|
||||||
|
Set both before `npm run dist`:
|
||||||
|
- `SOURCE_DATE_EPOCH` — freezes timestamps so a rebuild with no content
|
||||||
|
changes produces the same hash. Bump per release, not per rebuild.
|
||||||
|
- `CSC_IDENTITY_AUTO_DISCOVERY=false` — stops electron-builder searching
|
||||||
|
for a signing cert (we don't sign; SECURITY.md rule 2).
|
||||||
|
|
||||||
|
## Native `<select>` popup theme
|
||||||
|
|
||||||
|
Chromium's native `<select>` popup uses the page's `color-scheme`. When it's
|
||||||
|
`"light dark"` (both accepted), Chromium picks by the OS scheme — so a
|
||||||
|
dark-themed app on a light OS shows a light popup and vice versa.
|
||||||
|
|
||||||
|
Fix, currently in [settings.html](settings.html):
|
||||||
|
```css
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
@media (prefers-color-scheme: light) { :root { color-scheme: light; } }
|
||||||
|
```
|
||||||
|
`nativeTheme.themeSource` (driven by `settings.theme`) sets
|
||||||
|
`prefers-color-scheme`, so this stays in sync automatically. Do not remove.
|
||||||
|
|
||||||
|
Chromium also respects `select option { background; color }` in the popup
|
||||||
|
on Windows — a belt-and-braces override alongside `color-scheme`.
|
||||||
|
|
||||||
|
## The resolver in Theseus is `resolver-web.mjs`, not `.js`
|
||||||
|
|
||||||
|
Packaged builds ship `Argus/src/lib/resolver-web.js` as `resolver-web.mjs`
|
||||||
|
(see `extraResources` in [package.json](package.json)). Renamed because
|
||||||
|
`resources/` has no adjacent `package.json` so a `.js` gets treated as
|
||||||
|
CommonJS and its `export`s fail. Dev reads the engine copy directly (Argus
|
||||||
|
is `type: module` so `.js` works there).
|
||||||
|
|
||||||
|
## The resolver's electrum WS `directIP` fallback
|
||||||
|
|
||||||
|
[main.js](main.js) passes `directIP: true` to `resolveHost` so the engine
|
||||||
|
can dial the chipnet electrum servers by pinned IP if system DNS is dead.
|
||||||
|
Do not remove — this is what keeps `.bch` resolution working when the
|
||||||
|
adapter DNS is broken (a real failure mode we've hit).
|
||||||
|
|
||||||
|
## Publishing new hashes end-to-end
|
||||||
|
|
||||||
|
1. Build (see above).
|
||||||
|
2. Update `../site/releases-manifest.json` AND
|
||||||
|
`../site/tools/index.html` AND `../site/releases/index.html` with the
|
||||||
|
fresh SHA-256s + release date. All three must match.
|
||||||
|
3. Deploy:
|
||||||
|
```
|
||||||
|
scp dist-public/*.exe ../site/releases-manifest.json coinspectrum:/opt/silent-mode/dl/
|
||||||
|
cd ../Argus && node src/lib/sia-upload.js ../site bns/silentmode
|
||||||
|
```
|
||||||
|
4. On-chain (wallet spend, user's action):
|
||||||
|
`releases.silentmode.bch` currently publishes
|
||||||
|
`{"u":"https://dl.silentmode.st/releases-manifest.json"}` — a compact
|
||||||
|
`u` record because on-chain hashes wouldn't fit the 200-byte
|
||||||
|
OP_RETURN. Only re-publish if the URL changes or if we ever put full
|
||||||
|
hashes on-chain.
|
||||||
|
|
||||||
|
## Sia upload from this machine fails without a `hosts` pin
|
||||||
|
|
||||||
|
`node src/lib/sia-upload.js` fails with `getaddrinfo EAI_FAIL
|
||||||
|
coinspectrum.duckdns.org` unless the host is pinned in
|
||||||
|
`C:\Windows\System32\drivers\etc\hosts`:
|
||||||
|
`195.184.247.106 coinspectrum.duckdns.org`. This is a DNS quirk of the
|
||||||
|
machine, not a code bug — related to the strict-resolver behavior the
|
||||||
|
Ariadne daemon's EDNS fix addresses.
|
||||||
|
|
||||||
|
## Deploy directory quirks
|
||||||
|
|
||||||
|
- `/opt/silent-mode/dl/` is served as `dl.silentmode.st` — installers +
|
||||||
|
manifest live here.
|
||||||
|
- `silentmode.st` proxies to Sia (`bns/silentmode/`) — the HTML pages
|
||||||
|
live there.
|
||||||
|
- The `bns` gateway does NOT auto-index directories. `silentmode.st/apps/`
|
||||||
|
is a 404 (`NoSuchKey`); users must hit `/apps/index.html`. Fix upstream
|
||||||
|
in `bnsd.js` if it ever matters.
|
||||||
|
|
||||||
|
## Firefox needs a restart after Ariadne CA install/rotation
|
||||||
|
|
||||||
|
Firefox only reads root CAs at startup. The Ariadne installer offers a
|
||||||
|
`-CloseFirefox` task by default when Firefox is running so this doesn't
|
||||||
|
bite. Chrome/Edge pick up the new root immediately.
|
||||||
149
PENDING.md
Normal file
149
PENDING.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Pending — uncommitted Theseus changes
|
||||||
|
|
||||||
|
Working-tree state that hasn't been committed or shipped. Read this before
|
||||||
|
touching the same files. Update when you land or add work.
|
||||||
|
|
||||||
|
Format: each group has a `session:` label so parallel sessions can see
|
||||||
|
what belongs to whom and coordinate. Naming convention:
|
||||||
|
`YYYY-MM-DD:short-slug` for date-bounded work, or `parallel:<theme>` for
|
||||||
|
work owned by a session that isn't the current one.
|
||||||
|
|
||||||
|
Last updated: 2026-08-02.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## session: `2026-08-02:theseus-ux-polish`
|
||||||
|
|
||||||
|
Owner's focus: Theseus UX polish + downloads. Everything below bundles
|
||||||
|
into one commit + one Theseus rebuild + redeploy.
|
||||||
|
|
||||||
|
### Settings — General
|
||||||
|
|
||||||
|
- **Startup group** at the top of `#general` with "Open previous windows
|
||||||
|
and tabs" toggle (moved up from below).
|
||||||
|
- **Appearance** replaced with three visual **theme cards**: System / Light
|
||||||
|
/ Dark. `System` follows `nativeTheme.themeSource = "system"` so it
|
||||||
|
tracks the OS. Legacy `settings.theme` values outside `{system, light,
|
||||||
|
dark}` fall back to `system`.
|
||||||
|
- Files: [settings.html](settings.html), [main.js](main.js) (`applyTheme`
|
||||||
|
already handles all three values).
|
||||||
|
|
||||||
|
### Settings — new Search section
|
||||||
|
|
||||||
|
- Sidebar gets a **Search** top-level item between General and Naming.
|
||||||
|
- Search-engine controls moved OUT of General into this section.
|
||||||
|
- Only **enabled** engines listed in the main "Additional search engines"
|
||||||
|
block, grouped by kind (`Search engines` / `AI answer engines`),
|
||||||
|
drag-reorder within a kind, toggle to disable, ✕ to delete custom.
|
||||||
|
- **+ Add search engine** button toggles a catalog panel below with
|
||||||
|
built-in engines the user hasn't enabled (each with `+ Add`) + a "Or add
|
||||||
|
a custom URL" form.
|
||||||
|
- Files: [settings.html](settings.html) (nav, section, CSS `.engcat`, JS).
|
||||||
|
|
||||||
|
### Settings — dropdown color-scheme + layout
|
||||||
|
|
||||||
|
- `:root { color-scheme: dark }` + `@media (prefers-color-scheme: light)`
|
||||||
|
override. Native `<select>` popups on Windows now match app theme
|
||||||
|
because `nativeTheme.themeSource` drives `prefers-color-scheme`.
|
||||||
|
Fixes light popup on dark app (and vice versa).
|
||||||
|
- `.ctl` layout flipped to `flex-direction: row` with `flex-wrap: wrap`,
|
||||||
|
so anti-fingerprint mode select + value input fit side-by-side on wide
|
||||||
|
rows.
|
||||||
|
- Files: [settings.html](settings.html).
|
||||||
|
|
||||||
|
### Search catalog
|
||||||
|
|
||||||
|
- `SEARCH_ENGINES` entries gain `kind: "search" | "llm"` and
|
||||||
|
`tier: "catalog" | "extra"`. `allEngines()` propagates both; custom
|
||||||
|
user-added engines carry `tier: "custom"`.
|
||||||
|
- **Two-tier discovery in settings**: catalog panel has "Add from catalog"
|
||||||
|
(curated first-class disabled built-ins) + "Discover more engines" (wider
|
||||||
|
`tier:"extra"` bank, filtered by a live search box) + "Or add a custom URL".
|
||||||
|
- **Removed** from catalog (require sign-in before `?q=` works): ChatGPT,
|
||||||
|
Claude, You.com. **Kept** as LLM engines: Perplexity, Phind.
|
||||||
|
- **Removed** SearXNG (federated — `searx.be` is anti-bot-locked and every
|
||||||
|
single-instance default rots as instances rate-limit / die). Users who
|
||||||
|
want it add their preferred instance via the custom URL form.
|
||||||
|
- **Added** as `tier:"extra"`: Marginalia, Stract, Yep, Presearch, MetaGer,
|
||||||
|
Qwant, Swisscows, Naver, Baidu. All non-login `?q=` URLs. Same rot rule
|
||||||
|
as LLMs: if one starts bouncing to a login gate, drop it.
|
||||||
|
- Files: [main.js](main.js).
|
||||||
|
|
||||||
|
### Search picker
|
||||||
|
|
||||||
|
- [engine-picker.html](engine-picker.html) renders two headed groups:
|
||||||
|
"Search with" / "Ask an AI". Empty sections hidden.
|
||||||
|
- [settings.html](settings.html) default-engine `<select>` groups options
|
||||||
|
with `<optgroup>` by kind. Drag-reorder constrained to same-kind (a
|
||||||
|
cross-kind drop would re-group on next render).
|
||||||
|
|
||||||
|
### Chrome / toolbar
|
||||||
|
|
||||||
|
- **Loadbar collapses to 0px when idle** (`.loadbar { height: 0 }`,
|
||||||
|
`.loadbar.on { height: 2px }`, 120ms transition). Previously reserved
|
||||||
|
a permanent 2px strip below the address bar.
|
||||||
|
- **Downloads button** (`#downloads`) between the search box and Tor
|
||||||
|
toggle. Subscribes to `onDownloads` for badge + spin/done/err class.
|
||||||
|
- Files: [chrome.html](chrome.html).
|
||||||
|
|
||||||
|
### Downloads (new feature)
|
||||||
|
|
||||||
|
- New files: [downloads.html](downloads.html),
|
||||||
|
[downloads-preload.js](downloads-preload.js). Both added to
|
||||||
|
`build.files` in [package.json](package.json).
|
||||||
|
- `installDownloadTracker()` in main.js hooks
|
||||||
|
`session.defaultSession.on("will-download", …)`. In-memory list only;
|
||||||
|
no cross-session persistence.
|
||||||
|
- IPC surface: `downloads-get`, `toggle-downloads`, `close-downloads`,
|
||||||
|
`downloads-resize`, `download-open`, `download-show`, `download-cancel`,
|
||||||
|
`download-clear`, `downloads-clear-all`.
|
||||||
|
- New `downloadsPop` WebContentsView, positioned via `positionDownloads()`
|
||||||
|
from `layout()`.
|
||||||
|
- Preload exposes `getDownloads`, `toggleDownloads`, `onDownloads`.
|
||||||
|
|
||||||
|
### Preview harness
|
||||||
|
|
||||||
|
- [_preview.html](_preview.html) — `window.DL_ITEMS` seed +
|
||||||
|
`__toggleDownloads` overlay; stub `window.theseus.getDownloads /
|
||||||
|
onDownloads / toggleDownloads`; `kind` on stub engines; LLM catalog
|
||||||
|
trimmed to Perplexity + Phind.
|
||||||
|
- [_settings-preview.html](_settings-preview.html) — `kind` on stub
|
||||||
|
engines; LLM catalog trimmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## session: `parallel:collision-policy`
|
||||||
|
|
||||||
|
Owner's focus: BCNR ↔ ICANN name collision handling. Present in the
|
||||||
|
working tree, not mine. Leave for that session to land or bundle by
|
||||||
|
explicit ask.
|
||||||
|
|
||||||
|
### Chrome + main-process changes
|
||||||
|
|
||||||
|
- [main.js](main.js) — big block of new IPC around collision policy +
|
||||||
|
soft-collision picker + electrum discovery.
|
||||||
|
- [popover.html](popover.html), [popover-preload.js](popover-preload.js)
|
||||||
|
— collision switcher on the site-info popover.
|
||||||
|
- [preload.js](preload.js) — `collisionSwitch` + `collisionState`.
|
||||||
|
- [settings-preload.js](settings-preload.js) — same.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- Entire **Naming** section in [settings.html](settings.html) (BCNR/ICANN
|
||||||
|
collision policy radio group + remembered choices reset button).
|
||||||
|
Untouched by my changes.
|
||||||
|
|
||||||
|
### New files (untracked in git)
|
||||||
|
|
||||||
|
- [collision.html](collision.html), [collision-preload.js](collision-preload.js)
|
||||||
|
— the soft-collision "Open with…" prompt window (referenced from
|
||||||
|
`main.js:219`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live state
|
||||||
|
|
||||||
|
Shipped hashes live at `../site/releases-manifest.json` and
|
||||||
|
`https://dl.silentmode.st/releases-manifest.json`. On-chain pointer at
|
||||||
|
`releases.silentmode.bch`. See [GOTCHAS.md](GOTCHAS.md) for the release
|
||||||
|
gotchas.
|
||||||
11
downloads-preload.js
Normal file
11
downloads-preload.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
contextBridge.exposeInMainWorld("dl", {
|
||||||
|
onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)),
|
||||||
|
close: () => ipcRenderer.invoke("close-downloads"),
|
||||||
|
resize: (h) => ipcRenderer.invoke("downloads-resize", h),
|
||||||
|
open: (id) => ipcRenderer.invoke("download-open", id),
|
||||||
|
show: (id) => ipcRenderer.invoke("download-show", id),
|
||||||
|
cancel: (id) => ipcRenderer.invoke("download-cancel", id),
|
||||||
|
clear: (id) => ipcRenderer.invoke("download-clear", id),
|
||||||
|
clearAll: () => ipcRenderer.invoke("downloads-clear-all"),
|
||||||
|
});
|
||||||
101
downloads.html
Normal file
101
downloads.html
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||||
|
html, body { margin: 0; background: transparent; }
|
||||||
|
.menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; }
|
||||||
|
.head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px 6px; border-bottom: 1px solid #ffffff12; }
|
||||||
|
.head .t { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: #8b98a9; }
|
||||||
|
.head .clr { font-size: 11.5px; color: #8b98a9; background: transparent; border: none; cursor: pointer; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.head .clr:hover { background: #ffffff10; color: #e7eaf1; }
|
||||||
|
.head .clr[disabled] { opacity: .35; cursor: default; }
|
||||||
|
.list { max-height: 380px; overflow-y: auto; }
|
||||||
|
.empty { padding: 22px 14px; text-align: center; color: #7f8aa0; font-size: 12.5px; }
|
||||||
|
.row { padding: 10px 14px; border-bottom: 1px solid #ffffff08; font-size: 12.5px; }
|
||||||
|
.row:last-child { border-bottom: 0; }
|
||||||
|
.row .top { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.row .fn { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #e7eaf1; font-weight: 500; }
|
||||||
|
.row .st { font-size: 10.5px; color: #7f8aa0; }
|
||||||
|
.row .st.err { color: #f6768a; }
|
||||||
|
.row .st.ok { color: #4fd1a5; }
|
||||||
|
.row .mt { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 4px; color: #7f8aa0; font-size: 11px; }
|
||||||
|
.bar { height: 4px; background: #ffffff10; border-radius: 999px; overflow: hidden; margin-top: 6px; }
|
||||||
|
.bar > i { display: block; height: 100%; background: linear-gradient(90deg, #4b7bec, #d6ff3d); border-radius: 999px; transition: width .18s linear; }
|
||||||
|
.bar.indet > i { width: 35%; animation: slide 1.1s infinite linear; }
|
||||||
|
@keyframes slide { 0% { transform: translateX(-110%); } 100% { transform: translateX(400%); } }
|
||||||
|
.btns { display: flex; gap: 4px; }
|
||||||
|
.btn { background: transparent; border: none; color: #8b98a9; cursor: pointer; padding: 2px 5px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.btn:hover { background: #ffffff10; color: #e7eaf1; }
|
||||||
|
.btn.rm:hover { color: #f6768a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; }
|
||||||
|
.head { border-bottom-color: rgba(0,0,0,.08); }
|
||||||
|
.head .t, .row .st, .row .mt, .empty, .btn { color: #7b8494; }
|
||||||
|
.head .clr:hover, .btn:hover { background: rgba(0,0,0,.05); color: #1a1f28; }
|
||||||
|
.row .fn { color: #1a1f28; }
|
||||||
|
.row { border-bottom-color: rgba(0,0,0,.06); }
|
||||||
|
.bar { background: rgba(0,0,0,.08); }
|
||||||
|
}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="menu">
|
||||||
|
<div class="head">
|
||||||
|
<span class="t">Downloads</span>
|
||||||
|
<button class="clr" id="clr" disabled>Clear finished</button>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="list"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const esc = (s) => String(s || "").replace(/</g, "<").replace(/"/g, """);
|
||||||
|
const fmt = (n) => {
|
||||||
|
n = Number(n) || 0;
|
||||||
|
if (n < 1024) return n + " B";
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||||
|
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
|
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||||
|
};
|
||||||
|
function report() { requestAnimationFrame(() => { try { window.dl.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
|
||||||
|
window.dl.onDownloads((items) => {
|
||||||
|
const list = $("list");
|
||||||
|
const has = items && items.length;
|
||||||
|
$("clr").disabled = !has || !items.some((d) => d.state !== "progressing");
|
||||||
|
if (!has) {
|
||||||
|
list.innerHTML = `<div class="empty">No downloads yet.</div>`;
|
||||||
|
report(); return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map((d) => {
|
||||||
|
const done = d.state === "completed";
|
||||||
|
const err = d.state === "interrupted";
|
||||||
|
const cancel = d.state === "cancelled";
|
||||||
|
const inflight = d.state === "progressing" || d.state === "paused";
|
||||||
|
const pct = d.total > 0 ? Math.min(100, Math.round((d.received / d.total) * 100)) : 0;
|
||||||
|
const barStyle = d.total > 0 ? `width:${pct}%` : "width:35%";
|
||||||
|
const barClass = inflight && d.total === 0 ? "bar indet" : "bar";
|
||||||
|
const statusText = done ? "Done" : err ? "Failed" : cancel ? "Cancelled" : (d.total > 0 ? pct + "%" : "…");
|
||||||
|
const statusCls = done ? "st ok" : (err || cancel) ? "st err" : "st";
|
||||||
|
const sizeText = d.total > 0
|
||||||
|
? `${fmt(d.received)} / ${fmt(d.total)}`
|
||||||
|
: (d.received > 0 ? fmt(d.received) : "");
|
||||||
|
const actions = inflight
|
||||||
|
? `<button class="btn" data-cancel="${d.id}" title="Cancel">✕</button>`
|
||||||
|
: (done
|
||||||
|
? `<button class="btn" data-open="${d.id}" title="Open">Open</button>
|
||||||
|
<button class="btn" data-show="${d.id}" title="Show in folder">Folder</button>
|
||||||
|
<button class="btn rm" data-clear="${d.id}" title="Remove from list">✕</button>`
|
||||||
|
: `<button class="btn rm" data-clear="${d.id}" title="Remove from list">✕</button>`);
|
||||||
|
return `<div class="row">
|
||||||
|
<div class="top"><span class="fn" title="${esc(d.filename)}">${esc(d.filename)}</span><span class="${statusCls}">${statusText}</span></div>
|
||||||
|
${inflight || d.total > 0 ? `<div class="${barClass}"><i style="${barStyle}"></i></div>` : ""}
|
||||||
|
<div class="mt"><span>${esc(sizeText)}</span><span class="btns">${actions}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
for (const el of list.querySelectorAll("[data-open]")) el.onclick = () => window.dl.open(+el.dataset.open);
|
||||||
|
for (const el of list.querySelectorAll("[data-show]")) el.onclick = () => window.dl.show(+el.dataset.show);
|
||||||
|
for (const el of list.querySelectorAll("[data-cancel]"))el.onclick = () => window.dl.cancel(+el.dataset.cancel);
|
||||||
|
for (const el of list.querySelectorAll("[data-clear]")) el.onclick = () => window.dl.clear(+el.dataset.clear);
|
||||||
|
report();
|
||||||
|
});
|
||||||
|
$("clr").onclick = () => window.dl.clearAll();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
|
@ -21,8 +21,7 @@
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<div class="hdr">Search with</div>
|
<div id="groups"></div>
|
||||||
<div id="list"></div>
|
|
||||||
<div class="sep"></div>
|
<div class="sep"></div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<div class="item" id="settings"><span class="ic"><span class="em">⚙️</span></span><span class="nm">Search settings…</span></div>
|
<div class="item" id="settings"><span class="ic"><span class="em">⚙️</span></span><span class="nm">Search settings…</span></div>
|
||||||
|
|
@ -32,16 +31,27 @@
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const esc = (s) => String(s || "").replace(/</g, "<");
|
const esc = (s) => String(s || "").replace(/</g, "<");
|
||||||
function report() { requestAnimationFrame(() => { try { window.picker.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
|
function report() { requestAnimationFrame(() => { try { window.picker.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
|
||||||
window.picker.onData ? null : null;
|
const KINDS = [
|
||||||
window.picker.onEngines((d) => {
|
{ key: "search", label: "Search with" },
|
||||||
const list = $("list");
|
{ key: "llm", label: "Ask an AI" },
|
||||||
list.innerHTML = (d.engines || []).map((e) => {
|
];
|
||||||
|
const row = (e, current) => {
|
||||||
const ic = e.favicon
|
const ic = e.favicon
|
||||||
? `<img src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'em',textContent:'${(e.sym||'🔍')}'}))">`
|
? `<img src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'em',textContent:'${(e.sym||'🔍')}'}))">`
|
||||||
: `<span class="em">${e.sym || "🔍"}</span>`;
|
: `<span class="em">${e.sym || "🔍"}</span>`;
|
||||||
return `<div class="item" data-id="${esc(e.id)}"><span class="ic">${ic}</span><span class="nm">${esc(e.name)}</span>${e.id === d.current ? '<span class="chk">✓</span>' : ""}</div>`;
|
return `<div class="item" data-id="${esc(e.id)}"><span class="ic">${ic}</span><span class="nm">${esc(e.name)}</span>${e.id === current ? '<span class="chk">✓</span>' : ""}</div>`;
|
||||||
|
};
|
||||||
|
window.picker.onEngines((d) => {
|
||||||
|
const engines = d.engines || [];
|
||||||
|
const groups = $("groups");
|
||||||
|
// Preserve engineOrder within each kind (engines already come sorted).
|
||||||
|
const sections = KINDS.map(({ key, label }) => {
|
||||||
|
const rows = engines.filter((e) => (e.kind || "search") === key).map((e) => row(e, d.current)).join("");
|
||||||
|
if (!rows) return ""; // hide a section with no engines
|
||||||
|
return `<div class="hdr">${label}</div>${rows}`;
|
||||||
}).join("");
|
}).join("");
|
||||||
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.picker.pick(el.dataset.id));
|
groups.innerHTML = sections;
|
||||||
|
groups.querySelectorAll(".item").forEach((el) => el.onclick = () => window.picker.pick(el.dataset.id));
|
||||||
report();
|
report();
|
||||||
});
|
});
|
||||||
$("settings").onclick = () => window.picker.openSettings();
|
$("settings").onclick = () => window.picker.openSettings();
|
||||||
|
|
|
||||||
45
main.js
45
main.js
|
|
@ -26,26 +26,41 @@ const RESOLVER = app.isPackaged
|
||||||
// Catalog of built-in engines (users pick which to enable + can add their own).
|
// Catalog of built-in engines (users pick which to enable + can add their own).
|
||||||
// fav = the domain to load a real favicon from; sym = emoji fallback.
|
// fav = the domain to load a real favicon from; sym = emoji fallback.
|
||||||
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
|
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
|
||||||
// The picker + settings render each kind in its own section; kind stays a
|
// tier = "catalog" (curated first-class options shown in the primary Add
|
||||||
// display-only grouping (URL routing is the same for both).
|
// panel) or "extra" (a wider bank hidden behind a filter box for
|
||||||
|
// discovery). Missing tier defaults to "catalog".
|
||||||
|
// URL routing is identical for all — kind and tier are display-only grouping.
|
||||||
const SEARCH_ENGINES = {
|
const SEARCH_ENGINES = {
|
||||||
duckduckgo: { kind: "search", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
|
duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
|
||||||
google: { kind: "search", name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
|
google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
|
||||||
brave: { kind: "search", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
|
brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
|
||||||
bing: { kind: "search", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
|
bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
|
||||||
startpage: { kind: "search", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
|
startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
|
||||||
yandex: { kind: "search", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
|
yandex: { kind: "search", tier: "catalog", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
|
||||||
ecosia: { kind: "search", name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
|
ecosia: { kind: "search", tier: "catalog", name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
|
||||||
mojeek: { kind: "search", name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
|
mojeek: { kind: "search", tier: "catalog", name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
|
||||||
// SearXNG is federated (dozens of public instances at searx.space); any single
|
// SearXNG is federated (dozens of public instances at searx.space); any single
|
||||||
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
|
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
|
||||||
// Users who want SearXNG add their preferred instance via the custom URL form.
|
// Users who want SearXNG add their preferred instance via the custom URL form.
|
||||||
wikipedia: { kind: "search", name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) },
|
wikipedia: { kind: "search", tier: "catalog", name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) },
|
||||||
// AI / LLM answer engines that ANSWER the URL query without requiring a login.
|
// AI / LLM answer engines that ANSWER the URL query without requiring a login.
|
||||||
// ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
|
// ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
|
||||||
// ?q=, so they'd fail silently as a "search engine" — omitted deliberately.
|
// ?q=, so they'd fail silently as a "search engine" — omitted deliberately.
|
||||||
perplexity: { kind: "llm", name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
|
perplexity: { kind: "llm", tier: "catalog", name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
|
||||||
phind: { kind: "llm", name: "Phind", sym: "🧑💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) },
|
phind: { kind: "llm", tier: "catalog", name: "Phind", sym: "🧑💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) },
|
||||||
|
// ---- Extras: wider bank, discoverable via the Search filter box in Settings.
|
||||||
|
// These are known-working engines that don't require sign-in on ?q= but aren't
|
||||||
|
// first-class enough to sit in the primary catalog. Keep the list vetted — if
|
||||||
|
// an entry starts bouncing to a login gate, drop it (same rule as the LLMs).
|
||||||
|
marginalia: { kind: "search", tier: "extra", name: "Marginalia", sym: "🕸", fav: "search.marginalia.nu", url: (q) => "https://search.marginalia.nu/search?query=" + encodeURIComponent(q) },
|
||||||
|
stract: { kind: "search", tier: "extra", name: "Stract", sym: "🧵", fav: "stract.com", url: (q) => "https://stract.com/search?q=" + encodeURIComponent(q) },
|
||||||
|
yep: { kind: "search", tier: "extra", name: "Yep", sym: "✳️", fav: "yep.com", url: (q) => "https://yep.com/web?q=" + encodeURIComponent(q) },
|
||||||
|
presearch: { kind: "search", tier: "extra", name: "Presearch", sym: "🔷", fav: "presearch.com", url: (q) => "https://presearch.com/search?q=" + encodeURIComponent(q) },
|
||||||
|
metager: { kind: "search", tier: "extra", name: "MetaGer", sym: "🇩🇪", fav: "metager.org", url: (q) => "https://metager.org/meta/meta.ger3?eingabe=" + encodeURIComponent(q) },
|
||||||
|
qwant: { kind: "search", tier: "extra", name: "Qwant", sym: "🇫🇷", fav: "www.qwant.com", url: (q) => "https://www.qwant.com/?q=" + encodeURIComponent(q) },
|
||||||
|
swisscows: { kind: "search", tier: "extra", name: "Swisscows", sym: "🐄", fav: "swisscows.com", url: (q) => "https://swisscows.com/en/web?query=" + encodeURIComponent(q) },
|
||||||
|
naver: { kind: "search", tier: "extra", name: "Naver", sym: "🇰🇷", fav: "www.naver.com", url: (q) => "https://search.naver.com/search.naver?query=" + encodeURIComponent(q) },
|
||||||
|
baidu: { kind: "search", tier: "extra", name: "Baidu", sym: "🇨🇳", fav: "www.baidu.com", url: (q) => "https://www.baidu.com/s?wd=" + encodeURIComponent(q) },
|
||||||
};
|
};
|
||||||
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
|
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
|
||||||
// catalog and can be turned on from Settings. Custom + detected engines are always on.
|
// catalog and can be turned on from Settings. Custom + detected engines are always on.
|
||||||
|
|
@ -59,9 +74,9 @@ function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).inc
|
||||||
// enabledEngines; custom engines are always enabled.
|
// enabledEngines; custom engines are always enabled.
|
||||||
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", 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, 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", 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, enabled: true });
|
||||||
// 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) => {
|
||||||
|
|
|
||||||
|
|
@ -179,15 +179,23 @@
|
||||||
<button id="engAddBtn" class="btn" type="button">+ Add search engine</button>
|
<button id="engAddBtn" class="btn" type="button">+ Add search engine</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="engineList"></div>
|
<div id="engineList"></div>
|
||||||
<!-- Catalog: hidden until "Add" is clicked. Shows built-in engines the user
|
<!-- Catalog: hidden until "Add" is clicked. Three tiers, top to bottom:
|
||||||
hasn't enabled + a custom-URL form. -->
|
1. curated built-ins the user hasn't enabled (tier="catalog")
|
||||||
|
2. wider bank filtered by a search box (tier="extra")
|
||||||
|
3. custom-URL form -->
|
||||||
<div id="engineCatalog" class="engcat" hidden>
|
<div id="engineCatalog" class="engcat" hidden>
|
||||||
<div class="ehdr">Add from catalog</div>
|
<div class="ehdr">Add from catalog</div>
|
||||||
<div id="catalogList"></div>
|
<div id="catalogList"></div>
|
||||||
|
<div class="ehdr" style="display:flex;align-items:center;justify-content:space-between;gap:8px">
|
||||||
|
<span>Discover more engines</span>
|
||||||
|
<input id="engineFilter" type="search" placeholder="Filter by name…" autocomplete="off"
|
||||||
|
style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;min-width:auto;width:170px">
|
||||||
|
</div>
|
||||||
|
<div id="extraList"></div>
|
||||||
<div class="ehdr">Or add a custom URL</div>
|
<div class="ehdr">Or add a custom URL</div>
|
||||||
<div class="addeng">
|
<div class="addeng">
|
||||||
<input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none">
|
<input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none">
|
||||||
<input id="engName" placeholder="Name (e.g. SearXNG)">
|
<input id="engName" placeholder="Name (e.g. My SearXNG)">
|
||||||
<input id="engUrl" placeholder="https://example.com/search?q=%s">
|
<input id="engUrl" placeholder="https://example.com/search?q=%s">
|
||||||
<button id="engAdd" class="btn">Add</button>
|
<button id="engAdd" class="btn">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -366,20 +374,41 @@
|
||||||
if (!rows) return "";
|
if (!rows) return "";
|
||||||
return `<div class="ehdr">${label}</div>${rows}`;
|
return `<div class="ehdr">${label}</div>${rows}`;
|
||||||
}).join("");
|
}).join("");
|
||||||
// Catalog = built-in engines the user has NOT enabled, ready to add.
|
// Two catalog panes, split by tier:
|
||||||
|
// catalog — curated first-class built-ins the user hasn't enabled
|
||||||
|
// extra — wider bank, filtered live by the "Discover more" search box
|
||||||
const cat = document.getElementById("catalogList");
|
const cat = document.getElementById("catalogList");
|
||||||
if (cat) {
|
const extra = document.getElementById("extraList");
|
||||||
const off = d.engines.filter((e) => e.builtin && !e.enabled);
|
const filterInput = document.getElementById("engineFilter");
|
||||||
const catRow = (e) => `<div class="cat" data-id="${e.id}">` +
|
const catRow = (e) => `<div class="cat" data-id="${e.id}">` +
|
||||||
`<span class="eic">${engIcon(e)}</span>` +
|
`<span class="eic">${engIcon(e)}</span>` +
|
||||||
`<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>`;
|
||||||
cat.innerHTML = off.length
|
const off = d.engines.filter((e) => e.builtin && !e.enabled);
|
||||||
? off.map(catRow).join("")
|
const catalogOff = off.filter((e) => (e.tier || "catalog") === "catalog");
|
||||||
: `<div class="cempty2">All built-in engines are enabled. Add a custom URL below.</div>`;
|
const extraOff = off.filter((e) => e.tier === "extra");
|
||||||
|
if (cat) {
|
||||||
|
cat.innerHTML = catalogOff.length
|
||||||
|
? catalogOff.map(catRow).join("")
|
||||||
|
: `<div class="cempty2">All curated engines are enabled. 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) {
|
||||||
|
const paintExtras = (q) => {
|
||||||
|
const filt = String(q || "").trim().toLowerCase();
|
||||||
|
const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff;
|
||||||
|
extra.innerHTML = shown.length
|
||||||
|
? shown.map(catRow).join("")
|
||||||
|
: `<div class="cempty2">${filt ? "No engines match that filter." : "All discoverable engines are enabled."}</div>`;
|
||||||
|
extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
|
||||||
|
};
|
||||||
|
paintExtras(filterInput ? filterInput.value : "");
|
||||||
|
if (filterInput && !filterInput.dataset.wired) {
|
||||||
|
filterInput.dataset.wired = "1";
|
||||||
|
filterInput.addEventListener("input", () => paintExtras(filterInput.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
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));
|
list.querySelectorAll(".cx").forEach((b) => b.onclick = () => C.removeEngine(b.dataset.id).then(renderEngines));
|
||||||
// drag-and-drop reorder — same-kind only (dropping a Search engine into
|
// drag-and-drop reorder — same-kind only (dropping a Search engine into
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue