From 7087ab57cae1d5856edbf9914a0de1052acd1fb0 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 20:48:55 +0200 Subject: [PATCH] Theseus: extension panels on the left edge Extensions could only put panels in the right sidebar. An add-on can now register a panel with side: "left": its icon joins the quick-links strip (above the web-app links), and it opens in the strip's panel slot in its own view with the sidebar preload, so the add-on bridge, events and the widen/narrow controls work as on the right. A left panel and a quick-link web app never share the slot; reopening keeps the panel's page and state. Left panels drop out of the right sidebar and its dock. With the strip turned off they fall back to the right sidebar so they stay reachable. Pithos is the first: bundled copy rebuilt with side: "left". --- addons-host.js | 6 +- bundled-addons/pithos/index.js | 4 +- main.js | 116 ++++++++++++++++++++++++++++----- quicklinks.html | 29 +++++++++ 4 files changed, 135 insertions(+), 20 deletions(-) diff --git a/addons-host.js b/addons-host.js index ff29a5bc..2a14cae8 100644 --- a/addons-host.js +++ b/addons-host.js @@ -466,13 +466,15 @@ class AddonHost { // one of the add-on's HTML pages. `page` is a path RELATIVE to the // add-on folder. `title` shows in the sidebar tab strip. `icon` is // a short emoji/glyph. - registerSidebarPanel: ({ id, title, icon = manifest.icon, page }) => { + // `side: "left"` puts the panel in the quick-links strip on the left + // edge instead of the right sidebar (hosts without left panels ignore it). + registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side }) => { if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`); const abs = path.join(folder, String(page).replace(/^[\\/]/, "")); if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`); // Namespaced id so two add-ons can't collide. const panelId = `${manifest.id}:${id}`; - active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id }); + active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id, side: side === "left" ? "left" : "right" }); this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`); }, // Programmatically open the sidebar and switch to one of THIS add-on's diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index 4f366b5a..c44406bd 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -176,7 +176,9 @@ module.exports = { }); }); - api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html" }); + // Left edge, beside the quick links. Theseus builds without left panels + // ignore `side` and show it in the right sidebar. + api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html", side: "left" }); // The host has no quit hook for add-ons; without this an s3d we started // would outlive Theseus. diff --git a/main.js b/main.js index f9311fa2..ea6899aa 100644 --- a/main.js +++ b/main.js @@ -2132,6 +2132,14 @@ const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-i let quickPanel; // the mini dedicated view for the active quick-link const QUICK_PANEL_W = 380; // Opera's sidebar panel sits around this width let activeQuickLinkId = null; // id of the quick-link whose panel is open, or null +// Add-on panels registered with side: "left" open from the same strip, in +// their own view (sidebar-preload, so the add-on bridge works) in the slot +// the quick-link panel uses; the two are never open together. +let leftPanel; +let leftPanelId = null; // panelId shown, or null when closed +let leftPanelLoadedId = null; // what leftPanel has loaded (kept while closed, so reopening keeps state) +let leftPanelMax = false; // widened by the panel (silentmode.sidebar.toggleMax) +const LEFT_PANEL_W = 400; // 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 // loadSettings() runs and persists any drag adjustment made by the user. @@ -2499,6 +2507,9 @@ function initAddons() { if (t.addonId !== addonId) continue; try { t.view?.webContents?.send("addon-event", msg, payload); } catch {} } + if (leftPanel && leftPanelLoadedId && leftPanelLoadedId.startsWith(addonId + ":")) { + try { leftPanel.webContents.send("addon-event", msg, payload); } catch {} + } if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return; try { sidebar.webContents.send("addon-event", msg, payload); } catch {} }, @@ -2947,11 +2958,20 @@ function layout() { } // Quick-link panel: a narrow mini-view just right of the strip, showing the // active quick-link's web app. Only takes space when something is open. - const panelW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0; + const quickW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0; if (quickPanel) { - quickPanel.setBounds({ x: leftW, y: chromeH, width: panelW, height: bodyH }); - try { quickPanel.setVisible(panelW > 0); } catch {} + quickPanel.setBounds({ x: leftW, y: chromeH, width: quickW, height: bodyH }); + try { quickPanel.setVisible(quickW > 0); } catch {} } + // An add-on's left panel; widened, it leaves the tabs a strip, like the + // right sidebar maximized. + const addonLeftW = (leftPanel && leftPanelId && !fsTab) + ? (leftPanelMax ? Math.max(LEFT_PANEL_W, width - leftW - 120) : LEFT_PANEL_W) : 0; + if (leftPanel) { + leftPanel.setBounds({ x: leftW, y: chromeH, width: addonLeftW, height: bodyH }); + try { leftPanel.setVisible(addonLeftW > 0); } catch {} + } + const panelW = quickW || addonLeftW; // 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. const sideW = sidebarVisible && !fsTab ? sidebarW : 0; @@ -3074,6 +3094,12 @@ function showLinkStatus(url) { function toggleSidebar() { setSidebar(!sidebarVisible); } // Everything the chrome needs to draw the extension dock + plug-in row: // panels, toolbar menus, which panel is open, and the user's dock prefs. +// Panels that asked for the left side live in the quick-links strip. With +// the strip turned off they fall back to the right sidebar so they stay +// reachable. +const isLeftPanel = (p) => p.side === "left" && !!settings.quickLinksShow; +const rightPanels = () => (addonHost ? addonHost.getSidebarPanels().filter((p) => !isLeftPanel(p)) : []); +const leftPanels = () => (addonHost ? addonHost.getSidebarPanels().filter(isLeftPanel) : []); function sidebarStatePayload() { // Runs on the pull path too (the chrome asks at boot), not only on pushes — // a fresh profile otherwise showed the quiet add-ons until something re-emitted. @@ -3081,7 +3107,7 @@ function sidebarStatePayload() { return { visible: sidebarVisible, active: sidebarActivePanelId, - panels: addonHost ? addonHost.getSidebarPanels() : [], + panels: rightPanels(), toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [], dock: { order: Array.isArray(settings.dockOrder) ? settings.dockOrder : [], @@ -3111,7 +3137,8 @@ function emitSidebarState() { } function setSidebar(show, panelId) { if (!sidebar) return; - const panels = addonHost ? addonHost.getSidebarPanels() : []; + if (show && panelId && leftPanels().some((p) => p.panelId === panelId)) { openLeftPanel(panelId); return; } + const panels = rightPanels(); if (show && panels.length === 0) { // No add-on offers a sidebar panel — silently ignore. Settings surfaces // the "install one" path. @@ -4178,6 +4205,16 @@ function createWindow() { win.contentView.addChildView(quickPanel); styleScrollbars(quickPanel.webContents); quickPanel.setVisible(false); + // Left add-on panels: same preload and IPC surface as the right sidebar. + leftPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } }); + leftPanel.webContents.setWindowOpenHandler(({ url }) => { + if (url && /^(?:https?|bns):/i.test(url)) createTab(url); + return { action: "deny" }; + }); + try { leftPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} + win.contentView.addChildView(leftPanel); + styleScrollbars(leftPanel.webContents); + leftPanel.setVisible(false); // Add-on approval overlay (approval-modal capability). Transparent view // over the tab area, loaded once, shown per request. approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); @@ -4234,7 +4271,7 @@ function createWindow() { // pumpApproval (and every later dapp request) until a full restart. if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} } for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} } - addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; unlockPop = null; jsDialogPop = null; + addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; approvalPop = null; unlockPop = null; jsDialogPop = null; linkStatusVisible = false; }); layout(); @@ -4943,7 +4980,7 @@ ipcMain.handle("sidebar-drag", (_e, deltaPx) => { return sidebarW; }); ipcMain.handle("sidebar-open", (_e, panelId) => { setSidebar(true, panelId); return sidebarVisible; }); -ipcMain.handle("sidebar-close", () => { setSidebar(false); return false; }); +ipcMain.handle("sidebar-close", (e) => { if (isLeftSender(e)) { closeLeftPanel(); return false; } setSidebar(false); return false; }); // Panel-driven sidebar maximize: fills the window with the sidebar (tab // area shrinks to zero-width), remembering the pre-max width so restore // returns cleanly. Doesn't persist across sessions — a fresh launch @@ -4963,10 +5000,17 @@ function setSidebarMaximized(next) { try { sidebar?.webContents.send("sidebar-max-change", sidebarMaximized); } catch {} return sidebarMaximized; } -ipcMain.handle("sidebar-maximize", () => setSidebarMaximized(true)); -ipcMain.handle("sidebar-restore", () => setSidebarMaximized(false)); -ipcMain.handle("sidebar-toggle-max", () => setSidebarMaximized(!sidebarMaximized)); -ipcMain.handle("sidebar-is-max", () => sidebarMaximized); +const isLeftSender = (e) => !!leftPanel && e.sender === leftPanel.webContents; +function setLeftPanelMax(next) { + leftPanelMax = !!next; + layout(); + try { leftPanel?.webContents.send("sidebar-max-change", leftPanelMax); } catch {} + return leftPanelMax; +} +ipcMain.handle("sidebar-maximize", (e) => isLeftSender(e) ? setLeftPanelMax(true) : setSidebarMaximized(true)); +ipcMain.handle("sidebar-restore", (e) => isLeftSender(e) ? setLeftPanelMax(false) : setSidebarMaximized(false)); +ipcMain.handle("sidebar-toggle-max", (e) => isLeftSender(e) ? setLeftPanelMax(!leftPanelMax) : setSidebarMaximized(!sidebarMaximized)); +ipcMain.handle("sidebar-is-max", (e) => isLeftSender(e) ? leftPanelMax : sidebarMaximized); ipcMain.handle("sidebar-state", () => sidebarStatePayload()); // ---- extension dock: reorder by drag, right-click menu -------------------- // Keys match the chrome's: "p:" for a sidebar panel button, @@ -4976,7 +5020,7 @@ ipcMain.handle("sidebar-state", () => sidebarStatePayload()); function dockKeys() { const keys = []; if (addonHost) { - for (const p of addonHost.getSidebarPanels()) if (!p.plugin) keys.push("p:" + p.panelId); + for (const p of rightPanels()) if (!p.plugin) keys.push("p:" + p.panelId); for (const m of addonHost.getToolbarMenus()) if (!m.plugin) keys.push("m:" + m.addonId); } const order = Array.isArray(settings.dockOrder) ? settings.dockOrder : []; @@ -5170,7 +5214,8 @@ function setAddonEnabled(id, enabled) { if (addonHost) addonHost.discoverAndActivate(); // Sidebar may need to close if its current panel came from an add-on we // just disabled. - const panels = addonHost ? addonHost.getSidebarPanels() : []; + if (leftPanelId && !leftPanels().some((p) => p.panelId === leftPanelId)) closeLeftPanel(); + const panels = rightPanels(); if (sidebarVisible && sidebarActivePanelId && !panels.find((p) => p.panelId === sidebarActivePanelId)) { sidebarActivePanelId = null; setSidebar(false); @@ -7131,8 +7176,10 @@ ipcMain.handle("settings-set", (_e, key, val) => { if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) { activeQuickLinkId = null; } + if (key === "quickLinksShow" && !settings.quickLinksShow) closeLeftPanel(); layout(); emitQuickLinksState(); + emitSidebarState(); } // Broadcast to every renderer that watches the live settings — the chrome // (toolbar sizes, URL-bar language chip) and every open settings tab (so @@ -7145,13 +7192,43 @@ ipcMain.handle("settings-set", (_e, key, val) => { // toggle — click to open the panel on that link, click the active one again // to close it, click a different one to switch. The strip subscribes via // onState so it can highlight the active icon. +function quickLinksPayload() { + return { + links: settings.quickLinks || [], + openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId, + panels: leftPanels().map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })), + }; +} function emitQuickLinksState() { - const payload = { links: settings.quickLinks || [], openId: activeQuickLinkId }; - try { quicklinks?.webContents.send("quicklinks-state", payload); } catch {} + try { quicklinks?.webContents.send("quicklinks-state", quickLinksPayload()); } catch {} +} +function openLeftPanel(panelId) { + const p = leftPanels().find((x) => x.panelId === panelId); + if (!leftPanel || !p) return false; + activeQuickLinkId = null; + leftPanelId = panelId; + if (leftPanelLoadedId !== panelId) { + leftPanelLoadedId = panelId; + leftPanelMax = false; + try { leftPanel.webContents.loadFile(p.pageFile); } catch (e) { console.warn("left panel loadFile failed:", e?.message); } + } + try { win.contentView.removeChildView(leftPanel); win.contentView.addChildView(leftPanel); } catch {} + layout(); + emitQuickLinksState(); + try { leftPanel.webContents.send("sidebar-visibility", true); } catch {} + return true; +} +function closeLeftPanel() { + if (!leftPanelId) return; + leftPanelId = null; + layout(); + emitQuickLinksState(); + try { leftPanel?.webContents.send("sidebar-visibility", false); } catch {} } function openQuickPanel(id) { const L = (settings.quickLinks || []).find((x) => x.id === id); if (!quickPanel || !L || !L.url) return; + if (leftPanelId) closeLeftPanel(); activeQuickLinkId = id; try { const cur = quickPanel.webContents.getURL(); @@ -7169,14 +7246,19 @@ function closeQuickPanel() { layout(); emitQuickLinksState(); } -ipcMain.handle("quicklinks-get-state", () => ({ links: settings.quickLinks || [], openId: activeQuickLinkId })); +ipcMain.handle("quicklinks-get-state", () => quickLinksPayload()); ipcMain.handle("quicklinks-open", (_e, id) => { if (!id) return false; + if (String(id).startsWith("p:")) { + const panelId = String(id).slice(2); + if (panelId === leftPanelId) closeLeftPanel(); else openLeftPanel(panelId); + return true; + } if (id === activeQuickLinkId) { closeQuickPanel(); return true; } openQuickPanel(String(id)); return true; }); -ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); return true; }); +ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); closeLeftPanel(); return true; }); ipcMain.handle("quicklinks-add", () => { openSettingsTab("general"); return true; }); function broadcastSettings() { try { chrome?.webContents.send("settings-update", settings); } catch {} diff --git a/quicklinks.html b/quicklinks.html index 2eb21b9e..6fdfff93 100644 --- a/quicklinks.html +++ b/quicklinks.html @@ -36,6 +36,8 @@ pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; } .icon:hover .tt { opacity: 1; transition-delay: .4s; } .add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; } + .sep { width: 22px; height: 1px; background: var(--line2); margin: 4px 0; } + .icon.emoji { font-size: 18px; } .add:hover { color: var(--acid); } @@ -68,6 +70,33 @@ const links = (data && data.links) || []; const openId = (data && data.openId) || null; icons.innerHTML = ""; + // Extension panels (registered with side: "left") come first. + const panels = (data && data.panels) || []; + for (const P of panels) { + const b = document.createElement("button"); + b.className = "icon"; + if (openId === P.id) b.classList.add("on"); + b.title = P.title; + if (/^data:image\//.test(P.icon)) { + const img = document.createElement("img"); + img.src = P.icon; + img.alt = ""; + b.appendChild(img); + } else { + b.classList.add(P.icon ? "emoji" : "letter"); + b.textContent = P.icon || (P.title || "?").slice(0, 1).toUpperCase(); + } + const tt = document.createElement("span"); + tt.className = "tt"; tt.textContent = P.title; + b.appendChild(tt); + b.onclick = () => Q && Q.open(P.id); + icons.appendChild(b); + } + if (panels.length && links.length) { + const sep = document.createElement("div"); + sep.className = "sep"; + icons.appendChild(sep); + } for (const L of links) { const b = document.createElement("button"); b.className = "icon";