Theseus Settings: a Sidebar section for the left strip

Quick links sat in a small block under General, and the panel behaviour
added this week (per-app widths, pins, the Opera-style auto-hide) had no
place in Settings at all. Settings › Sidebar now holds the strip switch,
the web apps (drag to reorder like Search engines, pin, reset a remembered
width, remove), the extension panels on the strip (show or hide each one,
pin, reset width) and three switches for when the open panel steps aside:
a new tab, the right side panel opening, a click in the page. Apps gains
"Add to sidebar", so an installed site can also open in the strip.
This commit is contained in:
Local Dev 2026-10-04 21:53:39 +02:00
parent c8ae8e04a1
commit 19dc0649dd
3 changed files with 159 additions and 31 deletions

29
main.js
View file

@ -584,6 +584,15 @@ const SETTINGS_DEFAULTS = {
{ id: "x", url: "https://x.com/", title: "X" },
{ id: "youtube", url: "https://www.youtube.com/", title: "YouTube" },
],
// Settings › Sidebar. Per-app panel widths and pins (keys "q:<link id>" /
// "p:<panelId>"), extension panels the user took off the strip, and when
// the open panel steps aside (Opera's rules, each one switchable).
leftPanelWidths: {},
leftPanelPinned: {},
leftPanelsHidden: [],
sidebarHideOnNewTab: true,
sidebarHideOnRightPanel: true,
sidebarHideOnPageClick: true,
// 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
// room to grow. The search box is fixed-width; hidden removes it.
@ -888,7 +897,7 @@ const SETTINGS_ONLY = new Set([
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
"password-setup", "password-status", "password-unlock", "password-update",
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
"settings-open-panel", "settings-section", "tor-state",
"settings-open-panel", "settings-section", "sidebar-left-panels", "tor-state",
"webapps-list", "webapps-open", "webapps-remove",
"vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock",
// Raw seeds and WIFs. No page uses these (add-ons go through the
@ -3532,7 +3541,7 @@ function setSidebar(show, panelId) {
sidebarActivePanelId = panel.panelId;
try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); }
}
hideLeftPanels();
if (settings.sidebarHideOnRightPanel !== false) hideLeftPanels();
sidebarVisible = true;
sidebar.setVisible(true);
try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {}
@ -5013,7 +5022,7 @@ ipcMain.handle("navigate", (e, input) => {
return navigateTab(t ? t.id : activeId, input);
});
ipcMain.handle("search", (_e, q) => { if (leftPanelMax) setLeftPanelMax(false); return navigateTab(activeId, SEARCH(q)); });
ipcMain.handle("new-tab", () => { hideLeftPanels(); return createTab(); });
ipcMain.handle("new-tab", () => { if (settings.sidebarHideOnNewTab !== false) hideLeftPanels(); return createTab(); });
ipcMain.handle("close-tab", (_e, id) => closeTab(id));
ipcMain.handle("switch-tab", (_e, id) => setActive(id));
// Tab context menu backing IPCs. All scoped to a specific tab id so the
@ -7927,10 +7936,11 @@ ipcMain.handle("settings-set", (_e, key, val) => {
// Quick-links strip: re-layout + push the new state to the strip's view so
// show/hide and link edits take effect without a relaunch. If the active
// panel's link was removed, close the panel.
if (key === "quickLinksShow" || key === "quickLinks") {
if (key === "quickLinksShow" || key === "quickLinks" || key === "leftPanelWidths" || key === "leftPanelPinned" || key === "leftPanelsHidden") {
if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) {
activeQuickLinkId = null;
}
if (leftPanelId && leftPanelHidden(leftPanelId)) closeLeftPanel();
if (key === "quickLinksShow" && !settings.quickLinksShow) closeLeftPanel();
layout();
emitQuickLinksState();
@ -7952,12 +7962,19 @@ function quickLinksPayload() {
links: settings.quickLinks || [],
openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId,
pinned: leftPanelPinned(),
panels: leftPanels().map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })),
panels: leftPanels().filter((p) => !leftPanelHidden(p.panelId)).map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })),
};
}
function emitQuickLinksState() {
try { quicklinks?.webContents.send("quicklinks-state", quickLinksPayload()); } catch {}
}
function leftPanelHidden(panelId) {
return Array.isArray(settings.leftPanelsHidden) && settings.leftPanelsHidden.includes("p:" + panelId);
}
// Settings › Sidebar lists every left extension panel, including hidden ones.
ipcMain.handle("sidebar-left-panels", () => leftPanels().map((p) => ({
id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "", hidden: leftPanelHidden(p.panelId),
})));
function leftPanelKey() {
if (activeQuickLinkId) return "q:" + activeQuickLinkId;
if (leftPanelId) return "p:" + leftPanelId;
@ -7997,7 +8014,7 @@ ipcMain.handle("quicklinks-pin", (e, on) => {
});
// home-preload reports a pointerdown in a web tab's page.
ipcMain.on("tab-pointerdown", (e) => {
if (!leftPanelKey() || leftPanelPinned()) return;
if (settings.sidebarHideOnPageClick === false || !leftPanelKey() || leftPanelPinned()) return;
const t = tabs.find((x) => x.view?.webContents === e.sender);
if (!t || t.id !== activeId) return;
hideLeftPanels();

View file

@ -94,6 +94,7 @@ contextBridge.exposeInMainWorld("cfg", {
recheckUpdate: () => ipcRenderer.invoke("recheck-update"),
// Sites installed as apps (Settings › Apps).
webapps: () => ipcRenderer.invoke("webapps-list"),
sidebarLeftPanels: () => ipcRenderer.invoke("sidebar-left-panels"),
webappOpen: (key) => ipcRenderer.invoke("webapps-open", String(key || "")),
webappRemove: (key) => ipcRenderer.invoke("webapps-remove", String(key || "")),
onWebappsChanged: (cb) => ipcRenderer.on("webapps-changed", () => cb()),

View file

@ -265,6 +265,7 @@
<nav class="side">
<div class="brand">⛓ Theseus</div>
<a data-sec="general" class="active">General</a>
<a data-sec="sidebar">Sidebar</a>
<a data-sec="language">Language</a>
<a data-sec="search">Search</a>
<a data-sec="passwords">Passwords</a>
@ -294,22 +295,6 @@
<button id="updCheck" class="btn">Check for updates</button>
</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>
<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">
@ -859,6 +844,43 @@
<h2 class="sub">Cookie pop-ups left alone</h2>
<div class="exlist" id="consentEx"></div>
</section>
<section id="sidebar" hidden>
<h1>Sidebar</h1>
<p class="lede">The strip on the left edge: web apps that open in a panel beside the page, and extension panels. Drag a panel's right edge to resize it (double-click the edge to reset). The pin keeps a panel open when you click the page.</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>
<h2 class="sub">Web apps</h2>
<p class="subd">Sites that open in the panel. A site you installed as an app can be added from Apps.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<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">Extension panels</h2>
<p class="subd">Extensions that open from the strip. Hide one to take it off the strip; the extension keeps working.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:6px">
<div id="sbPanels" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<h2 class="sub">When the panel steps aside</h2>
<p class="subd">The open panel hides (and keeps its place) when:</p>
<div class="row">
<div class="txt"><div class="t">You open a new tab</div></div>
<label class="sw"><input type="checkbox" id="sidebarHideOnNewTab"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">The right side panel opens</div></div>
<label class="sw"><input type="checkbox" id="sidebarHideOnRightPanel"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">You click the page</div><div class="d">Pinned panels stay open.</div></div>
<label class="sw"><input type="checkbox" id="sidebarHideOnPageClick"><span class="track"><span class="knob"></span></span></label>
</div>
</section>
<section id="apps" hidden>
<h1>Apps</h1>
<p class="lede">Sites you installed as apps. Each opens in its own window with its own taskbar button and shortcuts, and runs on the same Theseus underneath: same names, same protections, same sign-ins.</p>
@ -1148,7 +1170,8 @@
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "extensionsOnDemand", "walletAtLaunch", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"quickLinksShow", "translateAutoOffer"];
"quickLinksShow", "translateAutoOffer",
"sidebarHideOnNewTab", "sidebarHideOnRightPanel", "sidebarHideOnPageClick"];
// Text inputs that round-trip through settings-set on change. Trimmed; a
// cleared field writes an empty string, which main re-defaults from
// SETTINGS_DEFAULTS on next launch.
@ -1174,7 +1197,12 @@
const addBtn = document.getElementById("qlAdd");
if (!list || !addBtn) return;
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
// A row's pin and width share their key with the strip: "q:<id>".
const pinBtnHtml = (on, key) => `<button class="btn ghost" data-pin="${esc(key)}" title="${on ? "Pinned: stays open when you click the page" : "Not pinned: closes when you click the page"}" style="${on ? "color:var(--acid-text)" : ""}">${on ? "Pinned" : "Pin"}</button>`;
const widthBtnHtml = (w, key) => w ? `<button class="btn ghost" data-width="${esc(key)}" title="Panel width ${w} px">Reset width</button>` : "";
let cur = s;
function render(links) {
const pinned = cur.leftPanelPinned || {}, widths = cur.leftPanelWidths || {};
list.innerHTML = "";
if (!links.length) {
const empty = document.createElement("div");
@ -1182,19 +1210,59 @@
list.appendChild(empty);
return;
}
for (const L of links) {
links.forEach((L, i) => {
const key = "q:" + L.id;
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>
// Same drag-to-reorder rows as Search engines (.eng styles).
row.className = "eng";
row.draggable = true;
row.dataset.id = L.id;
row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
row.innerHTML = `<span class="grip" title="Drag to reorder">⠿</span><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:140px"><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)}${widths[key] ? " · " + Number(widths[key]) + " px" : ""}</div></div>
${pinBtnHtml(!!pinned[key], key)}${widthBtnHtml(widths[key], key)}
<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));
const now = (await C.get()).quickLinks || [];
C.set("quickLinks", now.filter((x) => x.id !== b.dataset.del));
});
// Drag to reorder; the strip follows through settings-set.
let dragId = null;
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("dragstart", (e) => { dragId = row.dataset.id; e.dataTransfer.effectAllowed = "move"; row.classList.add("dragging"); });
row.addEventListener("dragend", () => { row.classList.remove("dragging"); list.querySelectorAll(".eng").forEach((r) => r.classList.remove("over")); });
row.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (row.dataset.id !== dragId) row.classList.add("over"); });
row.addEventListener("dragleave", () => row.classList.remove("over"));
row.addEventListener("drop", async (e) => {
e.preventDefault(); row.classList.remove("over");
if (!dragId || dragId === row.dataset.id) return;
const now = ((await C.get()).quickLinks || []).slice();
const from = now.findIndex((x) => x.id === dragId), to = now.findIndex((x) => x.id === row.dataset.id);
if (from < 0 || to < 0) return;
const [moved] = now.splice(from, 1);
now.splice(to, 0, moved);
C.set("quickLinks", now);
});
});
wirePinWidth(list);
}
// Shared with the extension-panel list below.
window.__sbWirePinWidth = wirePinWidth;
function wirePinWidth(root) {
root.querySelectorAll("[data-pin]").forEach((b) => b.onclick = async () => {
const p = { ...((await C.get()).leftPanelPinned || {}) };
if (p[b.dataset.pin]) delete p[b.dataset.pin]; else p[b.dataset.pin] = true;
C.set("leftPanelPinned", p);
});
root.querySelectorAll("[data-width]").forEach((b) => b.onclick = async () => {
const w = { ...((await C.get()).leftPanelWidths || {}) };
delete w[b.dataset.width];
C.set("leftPanelWidths", w);
});
}
window.__sbButtons = { pinBtnHtml, widthBtnHtml };
render(s.quickLinks || []);
addBtn.onclick = async () => {
const t = String(titleIn.value || "").trim();
@ -1209,7 +1277,37 @@
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 || []));
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { cur = next; render(next.quickLinks || []); });
})();
// ---- Extension panels on the strip (Sidebar) ---------------------------
(function () {
const box = document.getElementById("sbPanels");
if (!box || !C.sidebarLeftPanels) return;
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
let cur = s;
async function render() {
let panels = []; try { panels = (await C.sidebarLeftPanels()) || []; } catch {}
const pinned = cur.leftPanelPinned || {}, widths = cur.leftPanelWidths || {};
const B = window.__sbButtons;
if (!panels.length) { box.innerHTML = `<div class="pmuted" style="font-size:12.5px">No extension opens from the strip.</div>`; return; }
box.innerHTML = panels.map((p) => {
const icon = /^data:image\//.test(p.icon) ? `<img src="${esc(p.icon)}" alt="" style="width:20px;height:20px">` : esc(p.icon || (p.title || "?").slice(0, 1));
return `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px">
<div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;flex:none">${icon}</div>
<div style="flex:1;min-width:140px"><div style="font-weight:600">${esc(p.title)}</div><div style="font-size:12px;color:var(--mut)">${p.hidden ? "Not on the strip" : "On the strip"}${widths[p.id] ? " · " + Number(widths[p.id]) + " px" : ""}</div></div>
${B ? B.pinBtnHtml(!!pinned[p.id], p.id) + B.widthBtnHtml(widths[p.id], p.id) : ""}
<button class="btn ghost" data-show="${esc(p.id)}">${p.hidden ? "Show" : "Hide"}</button></div>`;
}).join("");
box.querySelectorAll("[data-show]").forEach((b) => b.onclick = async () => {
const h = new Set((await C.get()).leftPanelsHidden || []);
if (h.has(b.dataset.show)) h.delete(b.dataset.show); else h.add(b.dataset.show);
C.set("leftPanelsHidden", [...h]);
});
if (window.__sbWirePinWidth) window.__sbWirePinWidth(box);
}
render();
document.addEventListener("section", (e) => { if (String(e.detail) === "sidebar") render(); });
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { cur = next; render(); });
})();
// ---- Translation-peer list editor (General) ---------------------------
// Same shape as the Quick-links editor above: add a row, trash a row,
@ -2530,6 +2628,7 @@
(a.icon ? `<img src="${escA(a.icon)}" alt="">` : `<span class="ph">🧩</span>`) +
`<div class="nm"><b>${escA(a.name)}</b><span>${escA(a.host)}${when ? " · installed " + escA(when) : ""}${a.open ? " · open now" : ""}</span></div>` +
`<button class="btn" data-act="open">Open</button>` +
`<button class="btn" data-act="sidebar" title="Also open it in the left sidebar panel">Add to sidebar</button>` +
`<button class="btn rm" data-act="remove">Remove…</button></div>`;
}).join("");
}
@ -2537,6 +2636,17 @@
const b = e.target.closest("button[data-act]"); if (!b) return;
const key = b.closest(".app").dataset.key;
if (b.dataset.act === "open") { await A.webappOpen(key); renderApps(); }
else if (b.dataset.act === "sidebar") {
const app = ((await A.webapps()) || []).find((x) => x.key === key);
if (!app || !app.startUrl) return;
const links = ((await A.get()).quickLinks || []).slice();
const host = (u) => { try { return new URL(u).host; } catch { return ""; } };
if (!links.some((L) => host(L.url) === host(app.startUrl))) {
links.push({ id: "app-" + String(key).replace(/[^a-z0-9]+/gi, "-").slice(0, 32), url: app.startUrl, title: app.name || host(app.startUrl) });
await A.set("quickLinks", links);
}
b.textContent = "In the sidebar"; b.disabled = true;
}
else { await A.webappRemove(key); renderApps(); }
});
document.addEventListener("section", (e) => { if (String(e.detail) === "apps") renderApps(); });