Theseus: sidebar panels open over the page or push it aside

An open sidebar panel (a web app or an extension panel) now has a bar on
top with its name, Maximize/Restore (it covers the page, up to the right
sidebar) and Close. How panels sit next to the page is a choice, in the
new gear above + on the strip and in Settings > Sidebar: over the page,
where it floats on the left of a page that keeps its width and a click in
the page closes it; or pushing the page aside, where it stays open until
closed. That choice replaces the pin, which only existed to keep a panel
open on a page click.

Also: a window laid out while minimized reads a zero-size area, so every
view was left at 0x0 and the window came back blank until resized.
Layout now skips a minimized window and runs again on restore.
This commit is contained in:
Local Dev 2026-10-07 23:28:01 +02:00
parent f803491844
commit 110d0157de
7 changed files with 195 additions and 69 deletions

133
main.js
View file

@ -594,7 +594,11 @@ const SETTINGS_DEFAULTS = {
// "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: {},
leftPanelPinned: {}, // retired with the pin button (0.3.86); kept so old files load cleanly
// How an open sidebar panel sits next to the page: "overlay" floats over it
// and a click in the page closes the panel; "push" moves the page aside and
// the panel stays until it is closed.
sidebarPanelMode: "overlay",
leftPanelsHidden: [],
sidebarHideOnNewTab: true,
sidebarHideOnRightPanel: true,
@ -2528,7 +2532,7 @@ function winOfWebContents(wc) {
const t = tabs.find((x) => x.view.webContents === wc);
if (t) return t.w || null;
for (const [view, w] of hostOf) if (view.webContents === wc) return windows.includes(w) ? w : null;
for (const v of [sidebar, quicklinks, quickPanel, leftPanel, panelGrip]) if (v && v.webContents === wc) return primaryW;
for (const v of [sidebar, quicklinks, quickPanel, leftPanel, panelGrip, panelHeader]) if (v && v.webContents === wc) return primaryW;
return null;
}
// Shared overlays (site info, pickers, downloads, password fill, link pill,
@ -2620,6 +2624,8 @@ const LEFT_PANEL_W = 400;
const LEFT_PANEL_W_MIN = 280;
const TAB_AREA_MIN = 320; // the panel never squeezes the page below this
let panelGrip; // panel-grip.html, laid over the panel's right edge
let panelHeader; // panel-header.html: name, Maximize/Restore, Close over the open panel
const PANEL_HEADER_H = 30;
let panelGripDragging = false;
const PANEL_GRIP_W = 6;
// Sidebar width is user-adjustable via a drag grip on the panel's left edge.
@ -3697,6 +3703,11 @@ function layout() {
}
function layoutHere() {
if (!winAlive()) return;
// A minimized window reports a zero-size content area: laying out then left
// every view at 0x0, and nothing laid it out again on restore, so the
// window came back blank until it was resized. Keep the last layout; the
// "restore" event redoes it.
try { if (win.isMinimized()) return; } catch {}
const primary = !curW || curW === primaryW;
if (fsTabId == null && !userFullscreen) { try { if (win.isFullScreen()) scheduleFullscreenCheck(); } catch {} }
const { width, height } = win.getContentBounds();
@ -3720,32 +3731,43 @@ function layoutHere() {
// 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 quickW = (primary && quickPanel && activeQuickLinkId && !fsTab) ? leftPanelWidth(QUICK_PANEL_W, width - leftW) : 0;
if (quickPanel && primary) {
quickPanel.setBounds({ x: leftW, y: chromeH, width: quickW, height: bodyH });
try { quickPanel.setVisible(quickW > 0); } catch {}
}
// 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 = primary && sidebarVisible && !fsTab ? sidebarW : 0;
// An open panel has its header bar on top. Maximized, it covers the tab
// area like a page of its own, up to the right sidebar.
const headH = (quickW || (primary && leftPanel && leftPanelId && !fsTab)) ? PANEL_HEADER_H : 0;
const quickShownW = quickW && leftPanelMax ? Math.max(quickW, width - leftW - sideW) : quickW;
if (quickPanel && primary) {
quickPanel.setBounds({ x: leftW, y: chromeH + headH, width: quickShownW, height: Math.max(0, bodyH - headH) });
try { quickPanel.setVisible(quickShownW > 0); } catch {}
}
// An add-on's left panel. Widened, it covers the tab area like a page of
// its own, up to the right sidebar, which keeps its width: the tabs keep
// their narrow-panel bounds underneath instead of being squeezed aside.
const addonLeftW = (primary && leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(LEFT_PANEL_W, width - leftW) : 0;
const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(addonLeftW, width - leftW - sideW) : addonLeftW;
if (leftPanel && primary) {
leftPanel.setBounds({ x: leftW, y: chromeH, width: addonLeftShownW, height: bodyH });
leftPanel.setBounds({ x: leftW, y: chromeH + headH, width: addonLeftShownW, height: Math.max(0, bodyH - headH) });
try { leftPanel.setVisible(addonLeftShownW > 0); } catch {}
}
const panelW = quickW || addonLeftW;
if (panelHeader && primary) {
const headW = quickShownW || addonLeftShownW;
panelHeader.setBounds({ x: leftW, y: chromeH, width: headW, height: headH });
try { panelHeader.setVisible(headH > 0 && headW > 0); } catch {}
}
if (panelGrip && primary) {
// At rest a thin strip on the boundary; while dragging, the whole body,
// so the drag keeps its events wherever the pointer goes.
const showGrip = panelW > 0 && !(leftPanelId && leftPanelMax);
const showGrip = panelW > 0 && !leftPanelMax;
if (panelGripDragging) panelGrip.setBounds({ x: 0, y: chromeH, width, height: bodyH });
else panelGrip.setBounds({ x: leftW + panelW - PANEL_GRIP_W / 2, y: chromeH, width: PANEL_GRIP_W, height: bodyH });
try { panelGrip.setVisible(showGrip || panelGripDragging); } catch {}
}
const tabX = leftW + panelW;
// "push": the page moves aside for the panel. "overlay": the page keeps its
// width and the panel floats over its left edge.
const tabX = leftW + (settings.sidebarPanelMode === "push" ? panelW : 0);
const tabW = Math.max(0, width - tabX - sideW);
for (const t of winTabs()) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
if (sidebar && primary) sidebar.setBounds({ x: tabX + tabW, y: chromeH, width: sideW, height: bodyH });
@ -4638,6 +4660,8 @@ function createTab(initial, opts = {}) {
tabs.splice(at, 0, tab);
}
win.contentView.addChildView(view);
// A panel floating over the page stays above the new tab.
if (curW === primaryW && leftPanelKey() && settings.sidebarPanelMode !== "push") raisePanelGrip();
wc.on("page-title-updated", (_e, title) => {
tab.title = title; emitTabs();
// Keep the top-of-history title in sync when a page's title loads late.
@ -5218,6 +5242,7 @@ function makeBrowserWindow({ bounds } = {}) {
bw.on("focus", () => focusWin(w));
bw.on("blur", closePopupsOnWindowBlur);
bw.on("resize", layout);
bw.on("restore", layout);
bw.on("enter-full-screen", layout);
// Fullscreen left from outside (the OS, or Electron on the page's behalf)
// while a page still held it: put the toolbar back and tell the page.
@ -5277,7 +5302,7 @@ function onWindowClose(w) {
if (w === primaryW) primaryW = heir;
for (const [view, host] of [...hostOf]) {
if (host !== w) continue;
const sidePanel = [sidebar, quicklinks, quickPanel, leftPanel, panelGrip].includes(view);
const sidePanel = [sidebar, quicklinks, quickPanel, leftPanel, panelGrip, panelHeader].includes(view);
if (!sidePanel) try { view.setVisible(false); } catch {}
raise(view, heir);
}
@ -5434,6 +5459,11 @@ function createWindow() {
raise(panelGrip);
panelGrip.webContents.loadFile("panel-grip.html");
panelGrip.setVisible(false);
panelHeader = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "panel-header-preload.js") } });
try { panelHeader.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#121826" : "#e4e8ee"); } catch {}
raise(panelHeader);
deferOverlayLoad(panelHeader, "panel-header.html");
panelHeader.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") } });
@ -5480,7 +5510,7 @@ function onLastWindowClosed() {
// 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; leftPanel = null; panelGrip = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null;
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; panelGrip = null; panelHeader = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null;
linkStatusVisible = false;
}
}
@ -6281,10 +6311,11 @@ function setLeftPanelMax(next) {
// One wide view at a time: a maximized right sidebar steps back, and the
// panel goes on top of tab views created since it opened.
if (sidebarMaximized) setSidebarMaximized(false);
raise(leftPanel, primaryW);
}
if (leftPanelMax) raisePanelGrip();
layout();
try { leftPanel?.webContents.send("sidebar-max-change", leftPanelMax); } catch {}
emitPanelHeader();
return leftPanelMax;
}
ipcMain.handle("sidebar-maximize", (e) => isLeftSender(e) ? setLeftPanelMax(true) : setSidebarMaximized(true));
@ -9000,6 +9031,7 @@ ipcMain.handle("settings-get", () => settings);
ipcMain.handle("settings-set", (_e, key, val) => {
// The default engine must be one that is enabled and not frozen, whichever path sets it.
if (key === "searchEngine" && !enabledEnginesList().some((e) => e.id === val)) return settings;
if (key === "sidebarPanelMode" && val !== "overlay" && val !== "push") return settings;
if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); }
if (key === "webrtcMode") applyWebRTCPolicy();
if (key === "dohMode" || key === "dohProvider" || key === "dohCustom") applyDoh();
@ -9038,7 +9070,8 @@ 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" || key === "leftPanelWidths" || key === "leftPanelPinned" || key === "leftPanelsHidden") {
if (key === "sidebarPanelMode") raisePanelGrip();
if (key === "quickLinksShow" || key === "quickLinks" || key === "leftPanelWidths" || key === "sidebarPanelMode" || key === "leftPanelsHidden") {
if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) {
activeQuickLinkId = null;
}
@ -9063,13 +9096,52 @@ function quickLinksPayload() {
return {
links: settings.quickLinks || [],
openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId,
pinned: leftPanelPinned(),
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 {}
emitPanelHeader();
}
// ---- the bar over an open panel -------------------------------------------
function panelHeaderState() {
let title = "";
if (activeQuickLinkId) { const L = (settings.quickLinks || []).find((x) => x.id === activeQuickLinkId); title = L ? (L.title || L.url || "") : ""; }
else if (leftPanelId) { const p = leftPanels().find((x) => x.panelId === leftPanelId); title = p ? (p.addonName || p.title || "") : ""; }
return { title, max: !!leftPanelMax, open: !!leftPanelKey() };
}
function emitPanelHeader() {
try { if (panelHeader && overlayLoaded(panelHeader)) panelHeader.webContents.send("panel-header-state", panelHeaderState()); } catch {}
}
const isHeaderSender = (e) => !!panelHeader && e.sender === panelHeader.webContents;
ipcMain.handle("panel-header-state", (e) => isHeaderSender(e) ? panelHeaderState() : null);
ipcMain.handle("panel-header-max", (e) => isHeaderSender(e) && leftPanelKey() ? setLeftPanelMax(!leftPanelMax) : false);
ipcMain.handle("panel-header-close", (e) => { if (isHeaderSender(e)) hideLeftPanels(); return true; });
// The gear above "+" on the strip: how panels sit next to the page, and the
// Sidebar settings. A native menu, so it can open past the strip's edge.
ipcMain.handle("quicklinks-menu", (e, rect) => {
if (!quicklinks || e.sender !== quicklinks.webContents || !primaryW?.win || primaryW.win.isDestroyed()) return false;
const setMode = (mode) => {
if (settings.sidebarPanelMode === mode) return;
settings.sidebarPanelMode = mode;
saveSettings();
raisePanelGrip();
layout();
broadcastSettings();
};
const push = settings.sidebarPanelMode === "push";
const menu = Menu.buildFromTemplate([
{ label: "Panels open over the page", type: "radio", checked: !push, click: () => setMode("overlay") },
{ label: "Panels push the page aside", type: "radio", checked: push, click: () => setMode("push") },
{ type: "separator" },
{ label: "Reset panel widths", enabled: !!(settings.leftPanelWidths && Object.keys(settings.leftPanelWidths).length), click: () => { settings.leftPanelWidths = {}; saveSettings(); layout(); } },
{ label: "Sidebar settings…", click: () => openSettingsTab("sidebar") },
]);
const r = rect || {};
const y = Math.round(Number(r.y) || 0) + (primaryW.win === win ? CHROME_H : primaryW.chromeH || CHROME_H);
menu.popup({ window: primaryW.win, x: Math.round((Number(r.x) || 0) + (Number(r.width) || 0)), y });
return true;
});
function leftPanelHidden(panelId) {
return Array.isArray(settings.leftPanelsHidden) && settings.leftPanelsHidden.includes("p:" + panelId);
}
@ -9089,34 +9161,22 @@ function leftPanelWidth(def, room) {
const max = Math.max(LEFT_PANEL_W_MIN, (room || 0) - TAB_AREA_MIN);
return Math.max(LEFT_PANEL_W_MIN, Math.min(max, saved || def));
}
// The grip has to stay above whichever panel view was raised last.
// The open panel, its header and the grip go on top of the tab views (over
// the page in "overlay" mode, and above tabs created since it opened).
function raisePanelGrip() {
if (!panelGrip) return;
raise(panelGrip, primaryW);
if (activeQuickLinkId && quickPanel) raise(quickPanel, primaryW);
if (leftPanelId && leftPanel) raise(leftPanel, primaryW);
if (panelHeader && leftPanelKey()) { loadOverlay(panelHeader); raise(panelHeader, primaryW); }
if (panelGrip) raise(panelGrip, primaryW);
}
// Opera-style: the left panel steps aside when the user opens something else
// (a new tab, the right sidebar). It is only hidden — the app keeps its
// state, so reopening it is instant and lands where the user left off.
// Opera's other rule: a click in the page closes the panel, unless the user
// pinned that app (settings.leftPanelPinned, keyed like leftPanelWidths).
function leftPanelPinned() {
const k = leftPanelKey();
return !!(k && settings.leftPanelPinned && settings.leftPanelPinned[k]);
}
ipcMain.handle("quicklinks-pin", (e, on) => {
if (!quicklinks || e.sender !== quicklinks.webContents) return false;
const k = leftPanelKey();
if (!k) return false;
const next = { ...(settings.leftPanelPinned || {}) };
if (on) next[k] = true; else delete next[k];
settings.leftPanelPinned = next;
saveSettings();
emitQuickLinksState();
return !!on;
});
// Opera's other rule: a panel floating over the page closes when the page is
// clicked. A panel that pushed the page aside stays until it is closed.
// home-preload reports a pointerdown in a web tab's page.
ipcMain.on("tab-pointerdown", (e) => {
if (settings.sidebarHideOnPageClick === false || !leftPanelKey() || leftPanelPinned()) return;
if (settings.sidebarPanelMode === "push" || settings.sidebarHideOnPageClick === false || !leftPanelKey()) return;
const t = tabs.find((x) => x.view?.webContents === e.sender);
if (!t || !isActiveId(t.id)) return;
hideLeftPanels();
@ -9186,6 +9246,7 @@ function openLeftPanel(panelId) {
function closeLeftPanel() {
if (!leftPanelId) return;
leftPanelId = null;
if (leftPanelMax) { leftPanelMax = false; try { leftPanel?.webContents.send("sidebar-max-change", false); } catch {} }
layout();
emitQuickLinksState();
try { leftPanel?.webContents.send("sidebar-visibility", false); } catch {}
@ -9194,6 +9255,7 @@ function openQuickPanel(id) {
const L = (settings.quickLinks || []).find((x) => x.id === id);
if (!quickPanel || !L || !L.url) return;
if (leftPanelId) closeLeftPanel();
if (activeQuickLinkId !== id) leftPanelMax = false;
activeQuickLinkId = id;
try {
const cur = quickPanel.webContents.getURL();
@ -9209,6 +9271,7 @@ function openQuickPanel(id) {
}
function closeQuickPanel() {
activeQuickLinkId = null;
if (leftPanelMax && !leftPanelId) leftPanelMax = false;
layout();
emitQuickLinksState();
}

View file

@ -104,6 +104,8 @@
"quicklinks.html",
"quicklinks-preload.js",
"panel-grip-preload.js",
"panel-header.html",
"panel-header-preload.js",
"panel-grip.html",
"engine-icons/**/*",
"lib/**/*",

8
panel-header-preload.js Normal file
View file

@ -0,0 +1,8 @@
// Preload for panel-header.html, the bar over an open sidebar panel.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("panelHeader", {
getState: () => ipcRenderer.invoke("panel-header-state"),
toggleMax: () => ipcRenderer.invoke("panel-header-max"),
close: () => ipcRenderer.invoke("panel-header-close"),
onState: (cb) => ipcRenderer.on("panel-header-state", (_e, s) => cb(s)),
});

48
panel-header.html Normal file
View file

@ -0,0 +1,48 @@
<!doctype html>
<html><head><meta charset="utf-8">
<style>
/* The bar over an open sidebar panel (a web app or an extension's panel):
its name, then Maximize/Restore and Close at the top right. */
:root {
color-scheme: light dark;
--bg:#121826; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.10);
--acid:#d6ff3d; --hover: rgba(255,255,255,.08);
}
@media (prefers-color-scheme: light) {
:root { --bg:#e4e8ee; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; --hover: rgba(0,0,0,.06); }
}
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); overflow: hidden; user-select: none;
font: 600 12.5px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.bar { display: flex; align-items: center; height: 100%; padding: 0 4px 0 12px; box-sizing: border-box;
border-bottom: 1px solid var(--line2); gap: 2px; }
.title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
button { width: 26px; height: 24px; border: none; border-radius: 6px; background: transparent; color: var(--mut);
display: grid; place-items: center; cursor: pointer; padding: 0; }
button:hover { background: var(--hover); color: var(--ink); }
button svg { width: 14px; height: 14px; display: block; }
</style></head>
<body>
<div class="bar">
<span class="title" id="title"></span>
<button id="max" title="Maximize"></button>
<button id="close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</div>
<script>
const H = window.panelHeader;
const ICON_MAX = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>';
const ICON_RESTORE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="12" height="12" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2"/></svg>';
const title = document.getElementById("title"), max = document.getElementById("max");
function render(s) {
title.textContent = (s && s.title) || "";
const isMax = !!(s && s.max);
max.innerHTML = isMax ? ICON_RESTORE : ICON_MAX;
max.title = isMax ? "Restore" : "Maximize";
}
max.onclick = () => H && H.toggleMax();
document.getElementById("close").onclick = () => H && H.close();
// Double-click the bar: maximize / restore, like a window title bar.
document.querySelector(".bar").addEventListener("dblclick", (e) => { if (e.target.closest("button")) return; H && H.toggleMax(); });
H && H.getState().then(render).catch(() => {});
H && H.onState(render);
</script>
</body></html>

View file

@ -3,7 +3,8 @@ contextBridge.exposeInMainWorld("quicklinks", {
getState: () => ipcRenderer.invoke("quicklinks-get-state"),
open: (id) => ipcRenderer.invoke("quicklinks-open", String(id || "")),
close: () => ipcRenderer.invoke("quicklinks-close"),
pin: (on) => ipcRenderer.invoke("quicklinks-pin", !!on),
// The gear: rect is the button's box in the strip, for placing the menu.
menu: (rect) => ipcRenderer.invoke("quicklinks-menu", rect),
add: () => ipcRenderer.invoke("quicklinks-add"),
onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)),
});

View file

@ -39,19 +39,17 @@
.sep { width: 22px; height: 1px; background: var(--line2); margin: 4px 0; }
.icon.emoji { font-size: 18px; }
.add:hover { color: var(--acid); }
/* Pin: keeps the open panel when the page is clicked. Shown only while a
panel is open; lit when that app is pinned. */
.pin { color: var(--mut); margin-bottom: 4px; }
.pin[hidden] { display: none; }
.pin.on { color: var(--acid); background: var(--acid-tint); }
.pin svg { width: 16px; height: 16px; }
/* Gear: how panels sit next to the page, and the Sidebar settings. */
.gear { color: var(--mut); margin-bottom: 2px; }
.gear:hover { color: var(--ink); }
.gear svg { width: 17px; height: 17px; }
</style></head>
<body>
<div class="strip">
<div class="spacer"></div>
<div class="icons" id="icons"></div>
<div class="spacer"></div>
<button class="icon pin" id="pinBtn" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6z"/><path d="M12 15v6"/></svg><span class="tt" id="pinTt">Keep open</span></button>
<button class="icon gear" id="gearBtn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="tt">Sidebar options</span></button>
<button class="icon add" id="addBtn" title="Add link">+<span class="tt">Add link</span></button>
</div>
<script>
@ -59,10 +57,11 @@
const icons = document.getElementById("icons");
const addBtn = document.getElementById("addBtn");
addBtn.onclick = () => Q && Q.add();
const pinBtn = document.getElementById("pinBtn");
const pinTt = document.getElementById("pinTt");
let pinned = false;
pinBtn.onclick = () => Q && Q.pin && Q.pin(!pinned);
const gearBtn = document.getElementById("gearBtn");
gearBtn.onclick = () => {
const r = gearBtn.getBoundingClientRect();
Q && Q.menu && Q.menu({ x: r.left, y: r.top, width: r.width, height: r.height });
};
// Bundled brand marks. Each SVG is tight (20×20 viewBox, no padding), the
// colour is baked in so icons read at a glance in both themes. Unknown ids
// fall back to a letter chip. All marks are purely to identify services.
@ -80,10 +79,6 @@
function render(data) {
const links = (data && data.links) || [];
const openId = (data && data.openId) || null;
pinned = !!(data && data.pinned);
pinBtn.hidden = !openId;
pinBtn.classList.toggle("on", pinned);
pinTt.textContent = pinned ? "Pinned: stays open when you click the page" : "Keep open when you click the page";
icons.innerHTML = "";
// Extension panels (registered with side: "left") come first.
const panels = (data && data.panels) || [];

View file

@ -939,11 +939,19 @@
</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>
<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 bar over an open panel maximizes it over the page, restores it, or closes it.</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">How panels open</h2>
<p class="subd">Also in the gear on the strip, above +.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div style="display:flex;flex-direction:column;gap:6px">
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="overlay"><span><b>Over the page</b> <span class="pmuted">— the panel floats over the left of the page, which keeps its width. A click in the page closes it.</span></span></label>
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="push"><span><b>Push the page aside</b> <span class="pmuted">— the page narrows to make room, and the panel stays open until you close it.</span></span></label>
</div>
</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">
@ -970,7 +978,7 @@
<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>
<div class="txt"><div class="t">You click the page</div><div class="d">Panels that open over the page. A panel that pushes the page aside stays open.</div></div>
<label class="sw"><input type="checkbox" id="sidebarHideOnPageClick"><span class="track"><span class="knob"></span></span></label>
</div>
</section>
@ -1291,12 +1299,11 @@
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>`;
// A row's width shares its key with the strip: "q:<id>".
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 || {};
const widths = cur.leftPanelWidths || {};
list.innerHTML = "";
if (!links.length) {
const empty = document.createElement("div");
@ -1314,7 +1321,7 @@
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)}
${widthBtnHtml(widths[key], key)}
<button class="btn ghost" data-del="${esc(L.id)}">Remove</button>`;
list.appendChild(row);
});
@ -1340,23 +1347,18 @@
C.set("quickLinks", now);
});
});
wirePinWidth(list);
wireWidth(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);
});
window.__sbWireWidth = wireWidth;
function wireWidth(root) {
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 };
window.__sbButtons = { widthBtnHtml };
render(s.quickLinks || []);
addBtn.onclick = async () => {
const t = String(titleIn.value || "").trim();
@ -1381,7 +1383,7 @@
let cur = s;
async function render() {
let panels = []; try { panels = (await C.sidebarLeftPanels()) || []; } catch {}
const pinned = cur.leftPanelPinned || {}, widths = cur.leftPanelWidths || {};
const 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) => {
@ -1389,7 +1391,7 @@
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) : ""}
${B ? 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 () => {
@ -1397,7 +1399,7 @@
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);
if (window.__sbWireWidth) window.__sbWireWidth(box);
}
render();
document.addEventListener("section", (e) => { if (String(e.detail) === "sidebar") render(); });
@ -1991,6 +1993,13 @@
// The active radio reflects the current setting; changing it just calls
// C.set — the F12 handler in main.js reads settings.devToolsDock each
// time DevTools opens, so no restart is needed for the choice to apply.
// Sidebar › How panels open. The gear on the strip changes it too.
{
const sync = (mode) => document.querySelectorAll('input[name="sidebarPanelMode"]').forEach((r) => { r.checked = (r.value === (mode === "push" ? "push" : "overlay")); });
sync(s.sidebarPanelMode);
document.querySelectorAll('input[name="sidebarPanelMode"]').forEach((r) => r.addEventListener("change", () => { if (r.checked) C.set("sidebarPanelMode", r.value); }));
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => sync(next.sidebarPanelMode));
}
const devToolsDock = s.devToolsDock || "bottom";
document.querySelectorAll('input[name="devToolsDock"]').forEach((r) => {
r.checked = (r.value === devToolsDock);