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";