Theseus 0.3.67: Opera-style quick-links strip on the left edge

New thin vertical column (44 px) on the left side of every page, Opera-style.
Click an icon to open its web app in a new tab; if a tab is already open on
that host, we focus it instead of stacking another one. Hidden in HTML
fullscreen so a video still fills the window; toggle via Settings › General ›
Quick links › Show the strip.

Defaults ship X, Telegram and WhatsApp. The Settings › General › Quick links
section lists the current entries with a Remove button each and a Title + URL
+ Add row that auto-prefixes https:// and auto-fills the title from the
hostname when empty. Edits write the whole settings.quickLinks array; the
strip view and the window layout react through settings-set, so no restart is
needed.

Settings › General › Updates panel also now runs standalone (no longer gated
by anything in the shared cfg.get().then() init), so a thrown exception in an
unrelated feature can't leave it stuck on "Loading…" any more — the version
line reads immediately and Check for Updates stays functional.
This commit is contained in:
Silent Mode 2026-10-02 23:49:39 +02:00
parent cb4d900c1a
commit 08ecd093d2
5 changed files with 218 additions and 7 deletions

65
main.js
View file

@ -499,6 +499,14 @@ const SETTINGS_DEFAULTS = {
// Sidebar width in px. Adjusted by dragging the grip on the panel's left // Sidebar width in px. Adjusted by dragging the grip on the panel's left
// edge; persisted across launches. Clamped to [200, 800] on load. // edge; persisted across launches. Clamped to [200, 800] on load.
sidebarWidth: 340, sidebarWidth: 340,
// Quick-links strip on the left edge (Opera-style). Thin vertical column of
// service shortcuts; click opens the URL in a new tab. Toggle via settings.
quickLinksShow: true,
quickLinks: [
{ id: "x", url: "https://x.com/", title: "X" },
{ id: "telegram", url: "https://web.telegram.org/", title: "Telegram" },
{ id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" },
],
// Toolbar bar sizes. The URL bar is flex:1 by default so it eats all // Toolbar bar sizes. The URL bar is flex:1 by default so it eats all
// remaining space; `compact`/`medium` cap it so the extension dock has // remaining space; `compact`/`medium` cap it so the extension dock has
// room to grow. The search box is fixed-width; hidden removes it. // room to grow. The search box is fixed-width; hidden removes it.
@ -1912,6 +1920,8 @@ let linkStatusW = 100, linkStatusH = 22;
// nothing until the user actively opens it, so the perf cost of an unused // nothing until the user actively opens it, so the perf cost of an unused
// add-on is nil. // add-on is nil.
let sidebar, sidebarVisible = false, sidebarActivePanelId = null; let sidebar, sidebarVisible = false, sidebarActivePanelId = null;
let quicklinks; // left-edge quick-links strip (Opera-style)
const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles
// Sidebar width is user-adjustable via a drag grip on the panel's left edge. // Sidebar width is user-adjustable via a drag grip on the panel's left edge.
// The value below is the default; settings.sidebarWidth overrides it once // The value below is the default; settings.sidebarWidth overrides it once
// loadSettings() runs and persists any drag adjustment made by the user. // loadSettings() runs and persists any drag adjustment made by the user.
@ -2718,16 +2728,24 @@ function layout() {
const chromeH = fsTab ? 0 : CHROME_H; const chromeH = fsTab ? 0 : CHROME_H;
chrome.setBounds({ x: 0, y: 0, width, height: chromeH }); chrome.setBounds({ x: 0, y: 0, width, height: chromeH });
const bodyH = Math.max(0, height - chromeH); const bodyH = Math.max(0, height - chromeH);
// Quick-links strip on the LEFT edge (optional, 44px). Hidden in HTML
// fullscreen so a video truly fills the window. The strip spans the full
// body height alongside the tab view.
const leftW = (quicklinks && settings.quickLinksShow && !fsTab) ? QUICKLINKS_W : 0;
if (quicklinks) {
quicklinks.setBounds({ x: 0, y: chromeH, width: leftW, height: bodyH });
try { quicklinks.setVisible(leftW > 0); } catch {}
}
// Sidebar (when visible) claims a fixed slice on the right; the tab views // Sidebar (when visible) claims a fixed slice on the right; the tab views
// shrink to fit alongside it. When hidden, tabs get the full width. // shrink to fit alongside it. When hidden, tabs get the full width.
const sideW = sidebarVisible && !fsTab ? sidebarW : 0; const sideW = sidebarVisible && !fsTab ? sidebarW : 0;
const tabW = Math.max(0, width - sideW); const tabW = Math.max(0, width - leftW - sideW);
for (const t of tabs) t.view.setBounds({ x: 0, y: chromeH, width: tabW, height: bodyH }); for (const t of tabs) t.view.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH });
if (sidebar) sidebar.setBounds({ x: tabW, y: chromeH, width: sideW, height: bodyH }); if (sidebar) sidebar.setBounds({ x: leftW + tabW, y: chromeH, width: sideW, height: bodyH });
// Approval overlay sits exactly over the tab area — the page underneath // Approval overlay sits exactly over the tab area — the page underneath
// keeps running; only pointer input is intercepted. // keeps running; only pointer input is intercepted.
if (approvalPop) approvalPop.setBounds({ x: 0, y: chromeH, width: tabW, height: bodyH }); if (approvalPop) approvalPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH });
if (jsDialogPop) jsDialogPop.setBounds({ x: 0, y: chromeH, width: tabW, height: bodyH }); if (jsDialogPop) jsDialogPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH });
positionPopover(); positionPopover();
positionEnginePicker(); positionEnginePicker();
positionDownloads(); positionDownloads();
@ -3768,6 +3786,14 @@ function createWindow() {
win.contentView.addChildView(sidebar); win.contentView.addChildView(sidebar);
styleScrollbars(sidebar.webContents); styleScrollbars(sidebar.webContents);
sidebar.setVisible(false); sidebar.setVisible(false);
// Opera-style quick-links strip on the left edge: a thin vertical column
// with icons for a few web apps (X, Telegram, WhatsApp by default). Clicking
// one opens its URL in a new tab (or focuses an existing tab for that URL).
quicklinks = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "quicklinks-preload.js") } });
try { quicklinks.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0e131c" : "#eceff3"); } catch {}
win.contentView.addChildView(quicklinks);
styleScrollbars(quicklinks.webContents);
quicklinks.webContents.loadFile("quicklinks.html");
// Add-on approval overlay (approval-modal capability). Transparent view // Add-on approval overlay (approval-modal capability). Transparent view
// over the tab area, loaded once, shown per request. // over the tab area, loaded once, shown per request.
approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } });
@ -6256,6 +6282,12 @@ ipcMain.handle("settings-set", (_e, key, val) => {
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null; const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
try { wc && wc.reload(); } catch {} try { wc && wc.reload(); } catch {}
} }
// Quick-links strip: re-layout + push the new list to the strip's view so
// show/hide and link edits take effect without a relaunch.
if (key === "quickLinksShow" || key === "quickLinks") {
layout();
try { quicklinks?.webContents.send("quicklinks-update", settings.quickLinks || []); } catch {}
}
// Broadcast to every renderer that watches the live settings — the chrome // Broadcast to every renderer that watches the live settings — the chrome
// (toolbar sizes, URL-bar language chip) and every open settings tab (so // (toolbar sizes, URL-bar language chip) and every open settings tab (so
// the General Website-language row and the Privacy Anti-fingerprinting // the General Website-language row and the Privacy Anti-fingerprinting
@ -6263,6 +6295,29 @@ ipcMain.handle("settings-set", (_e, key, val) => {
broadcastSettings(); broadcastSettings();
return settings; return settings;
}); });
// Quick-links IPC: read the saved list, open one in a new tab (focus an
// existing tab for that host if present), or prompt for a URL to add.
ipcMain.handle("quicklinks-get", () => settings.quickLinks || []);
ipcMain.handle("quicklinks-open", (_e, id) => {
const L = (settings.quickLinks || []).find((x) => x.id === id);
if (!L || !L.url) return false;
try {
// Focus an existing tab whose current URL is on the same host — avoids
// stacking a new tab every click.
const u = new URL(L.url);
const existing = tabs.find((t) => { try { return t.url && new URL(t.url).host === u.host; } catch { return false; } });
if (existing) { setActive(existing.id); return true; }
} catch {}
createTab(L.url);
return true;
});
ipcMain.handle("quicklinks-add", async () => {
// Minimal add flow for now: prompt via a native input dialog isn't ideal
// in Electron, so route the user to Settings where they can edit the list
// directly. (A proper picker UI can land later.)
openSettingsTab("general");
return true;
});
function broadcastSettings() { function broadcastSettings() {
try { chrome?.webContents.send("settings-update", settings); } catch {} try { chrome?.webContents.send("settings-update", settings); } catch {}
for (const t of tabs) { for (const t of tabs) {

View file

@ -1,6 +1,6 @@
{ {
"name": "theseus-navigator", "name": "theseus-navigator",
"version": "0.3.66", "version": "0.3.67",
"license": "MPL-2.0", "license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode", "author": "Silent Mode",
@ -93,6 +93,8 @@
"webapps.js", "webapps.js",
"js-dialog.html", "js-dialog.html",
"js-dialog-preload.js", "js-dialog-preload.js",
"quicklinks.html",
"quicklinks-preload.js",
"engine-icons/**/*", "engine-icons/**/*",
"lib/**/*", "lib/**/*",
"package.json", "package.json",
@ -158,3 +160,4 @@
} }
} }
} }

7
quicklinks-preload.js Normal file
View file

@ -0,0 +1,7 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("quicklinks", {
getLinks: () => ipcRenderer.invoke("quicklinks-get"),
open: (id) => ipcRenderer.invoke("quicklinks-open", String(id || "")),
add: () => ipcRenderer.invoke("quicklinks-add"),
onLinks: (cb) => ipcRenderer.on("quicklinks-update", (_e, d) => cb(d)),
});

82
quicklinks.html Normal file
View file

@ -0,0 +1,82 @@
<!doctype html>
<html>
<head><meta charset="utf-8">
<style>
:root {
color-scheme: light dark;
--bg:#0e131c; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.13);
--acid:#d6ff3d;
}
@media (prefers-color-scheme: light) {
:root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; }
}
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden;
user-select: none; }
.strip { display: flex; flex-direction: column; align-items: center;
padding: 10px 0; gap: 4px; height: 100%; box-sizing: border-box;
border-right: 1px solid var(--line2); }
.icon { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center;
cursor: pointer; background: transparent; border: none; padding: 0; color: var(--mut);
position: relative; }
.icon:hover { background: var(--line2); color: var(--ink); }
.icon:active { transform: scale(0.94); }
.icon svg { width: 20px; height: 20px; display: block; }
.icon img { width: 22px; height: 22px; display: block; border-radius: 4px; }
.icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%);
background: var(--bg); color: var(--ink); padding: 4px 8px; border-radius: 6px;
border: 1px solid var(--line2); white-space: nowrap; font-size: 12px;
pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; }
.icon:hover .tt { opacity: 1; transition-delay: .4s; }
.sep { width: 24px; height: 1px; background: var(--line2); margin: 6px 0; flex: none; }
.spacer { flex: 1 1 auto; }
/* Add button — small plus to add a custom link. Prompts for URL via main. */
.add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; }
.add:hover { color: var(--acid); }
</style></head>
<body>
<div class="strip" id="strip"></div>
<script>
const Q = window.quicklinks;
const strip = document.getElementById("strip");
// The links come from main (saved in settings.quickLinks). Each entry is
// { id, url, title, icon } where icon is either a letter, an emoji, or a
// data: URL for a bundled brand SVG.
function render(links) {
strip.innerHTML = "";
for (const L of (links || [])) {
const b = document.createElement("button");
b.className = "icon";
b.title = L.title || L.url;
// Icon: a data URL (img) or a short text badge.
if (L.icon && /^(data:|https?:|file:)/.test(L.icon)) {
const img = document.createElement("img");
img.src = L.icon; img.alt = "";
img.onerror = () => { b.textContent = (L.title || "?").slice(0, 1).toUpperCase(); };
b.appendChild(img);
} else if (L.svg) {
b.innerHTML = L.svg;
} else {
b.textContent = (L.title || "?").slice(0, 1).toUpperCase();
}
const tt = document.createElement("span");
tt.className = "tt"; tt.textContent = L.title || L.url;
b.appendChild(tt);
b.onclick = () => Q && Q.open(L.id);
strip.appendChild(b);
}
// Spacer pushes the + to the bottom.
const sp = document.createElement("div"); sp.className = "spacer";
strip.appendChild(sp);
const add = document.createElement("button");
add.className = "icon add"; add.textContent = "+"; add.title = "Add link";
const addTt = document.createElement("span"); addTt.className = "tt"; addTt.textContent = "Add link";
add.appendChild(addTt);
add.onclick = () => Q && Q.add();
strip.appendChild(add);
}
Q && Q.getLinks && Q.getLinks().then(render).catch(() => render([]));
Q && Q.onLinks && Q.onLinks(render);
</script>
</body>
</html>

View file

@ -293,6 +293,22 @@
<button id="updCheck" class="btn">Check for updates</button> <button id="updCheck" class="btn">Check for updates</button>
</div> </div>
</div> </div>
<h2 class="sub">Quick links</h2>
<p class="subd">Opera-style strip on the left edge with shortcuts to web apps. Click a row's URL to edit. Shows the first letter of the title when there's no icon.</p>
<div class="row">
<div class="txt"><div class="t">Show the strip</div><div class="d">A thin vertical column on the left side of every page.</div></div>
<label class="sw"><input type="checkbox" id="quickLinksShow"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div class="txt"><div class="t">Links</div></div>
<div id="qlList" style="display:flex;flex-direction:column;gap:6px"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<input id="qlTitle" type="text" placeholder="Title (e.g. Discord)" style="flex:1;min-width:120px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
<input id="qlUrl" type="text" placeholder="https://discord.com/app" style="flex:2;min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
<button id="qlAdd" class="btn">Add</button>
</div>
</div>
<h2 class="sub">Appearance</h2> <h2 class="sub">Appearance</h2>
<p class="subd">Choose a theme for the browser. <b>System</b> follows your operating system's light/dark setting.</p> <p class="subd">Choose a theme for the browser. <b>System</b> follows your operating system's light/dark setting.</p>
<div class="themeCards" id="theme" role="radiogroup" aria-label="Theme"> <div class="themeCards" id="theme" role="radiogroup" aria-label="Theme">
@ -1003,13 +1019,61 @@
}); });
})(); })();
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc", const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"]; "clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"quickLinksShow"];
C.get().then((s) => { C.get().then((s) => {
for (const k of TOGGLES) { for (const k of TOGGLES) {
const el = document.getElementById(k); if (!el) continue; const el = document.getElementById(k); if (!el) continue;
el.checked = !!s[k]; el.checked = !!s[k];
el.addEventListener("change", () => C.set(k, el.checked)); el.addEventListener("change", () => C.set(k, el.checked));
} }
// ---- Quick-links list editor (General) --------------------------------
// Add/remove rows; each change writes the whole settings.quickLinks array.
// The strip view and the main window layout react through settings-set.
(function () {
const list = document.getElementById("qlList");
const titleIn = document.getElementById("qlTitle");
const urlIn = document.getElementById("qlUrl");
const addBtn = document.getElementById("qlAdd");
if (!list || !addBtn) return;
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
function render(links) {
list.innerHTML = "";
if (!links.length) {
const empty = document.createElement("div");
empty.className = "pmuted"; empty.style.fontSize = "12.5px"; empty.textContent = "No links yet — add one below.";
list.appendChild(empty);
return;
}
for (const L of links) {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
row.innerHTML = `<div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;font-weight:600;color:var(--acid-text);flex:none">${esc((L.title || "?").slice(0,1).toUpperCase())}</div>
<div style="flex:1;min-width:0"><div style="font-weight:600">${esc(L.title || L.url)}</div><div style="font-size:12px;color:var(--mut);word-break:break-all">${esc(L.url)}</div></div>
<button class="btn ghost" data-del="${esc(L.id)}">Remove</button>`;
list.appendChild(row);
}
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
const cur = (await C.get()).quickLinks || [];
C.set("quickLinks", cur.filter((x) => x.id !== b.dataset.del));
});
}
render(s.quickLinks || []);
addBtn.onclick = async () => {
const t = String(titleIn.value || "").trim();
const u = String(urlIn.value || "").trim();
if (!u) return;
const url = /^https?:\/\//i.test(u) ? u : "https://" + u;
let title = t;
if (!title) { try { title = new URL(url).host.replace(/^www\./, ""); } catch { title = url; } }
const cur = (await C.get()).quickLinks || [];
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 24) + "-" + Date.now().toString(36).slice(-4);
C.set("quickLinks", [...cur, { id, url, title }]);
titleIn.value = ""; urlIn.value = "";
};
// Live-sync from main (edits from other surfaces, or the strip's own add).
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || []));
})();
// ---- website language (General page) — friendly wrapper over the // ---- website language (General page) — friendly wrapper over the
// same languageMode/languageValue setting the Anti-fingerprinting Language // same languageMode/languageValue setting the Anti-fingerprinting Language
// row edits. "Auto" = languageMode="show" (follow OS); anything else // row edits. "Auto" = languageMode="show" (follow OS); anything else