diff --git a/chrome.html b/chrome.html
index ad07070b..8c8ce46b 100644
--- a/chrome.html
+++ b/chrome.html
@@ -171,6 +171,10 @@
.extbtn:hover { background: var(--line2); color: var(--ink); }
.extbtn.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); box-shadow: inset 0 -2px 0 var(--acid); }
.extbtn.soon { opacity: .45; cursor: help; }
+ /* drag-reorder feedback, same language as tabs and bookmark chips */
+ .extbtn.dragging { opacity: .45; }
+ .extbtn.dropbefore { box-shadow: -2px 0 0 var(--acid); }
+ .extbtn.dropafter { box-shadow: 2px 0 0 var(--acid); }
.extbtn.soon:hover { opacity: .7; background: var(--line2); }
/* downloads button — a small badge sits over the icon when there's activity */
.dlbtn { position: relative; }
@@ -680,7 +684,7 @@
// an addon that declares a toolbar-menu both get one entry here. panels
// stay clickable-toggle; menus open a floating dropdown positioned under
// the icon and dispatch the picked item back to the add-on.
- let lastPanels = [], lastMenus = [], lastButtons = [], lastAllPanels = [], lastAllMenus = [];
+ let lastPanels = [], lastMenus = [], lastButtons = [], lastAllPanels = [], lastAllMenus = [], lastDock = { order: [], hidden: [] };
let sidebarOpen = false, activePanelId = null;
let openMenuAddonId = null; // which addon's menu dropdown is currently open
function renderSidebarState(d) {
@@ -704,6 +708,15 @@
for (const m of lastMenus) {
lastButtons.push({ kind: "menu", key: "m:" + m.addonId, addonId: m.addonId, icon: String(m.icon || "•"), title: m.title || m.addonId, items: m.items || [] });
}
+ // User prefs: drag-reorder order and buttons hidden via the right-click
+ // menu. Unknown keys keep registration order after the ordered ones.
+ if (d.dock) lastDock = d.dock;
+ const order = Array.isArray(lastDock.order) ? lastDock.order : [];
+ const hidden = new Set(Array.isArray(lastDock.hidden) ? lastDock.hidden : []);
+ const rank = (k) => { const i = order.indexOf(k); return i < 0 ? order.length : i; };
+ lastButtons = lastButtons.filter((b) => !hidden.has(b.key)).map((b, i) => ({ ...b, i })).sort((a, b) => rank(a.key) - rank(b.key) || a.i - b.i);
+ lastPanels = lastPanels.filter((p) => !hidden.has("p:" + p.panelId));
+ lastMenus = lastMenus.filter((m) => !hidden.has("m:" + m.addonId));
// Hide the dock entirely on a fresh install with no add-ons registered
// — no empty-slot artifact next to the download button.
extDock.hidden = lastButtons.length === 0;
@@ -719,8 +732,12 @@
// URI (svg or png) and we render it as an
instead — same size
// slot, no layout change. Anything else is HTML-escaped and stays
// text so the emoji case keeps working.
- return ``;
+ return ``;
}).join("");
+ // Drag-reorder: drop side is the half of the target button the pointer
+ // is on; main persists the order and re-emits state, which re-renders.
+ let dragKey = null;
+ const clearDrop = () => extButtons.querySelectorAll(".dropbefore,.dropafter").forEach((x) => x.classList.remove("dropbefore", "dropafter"));
extButtons.querySelectorAll(".extbtn").forEach((btn) => {
btn.onclick = () => {
if (btn.dataset.panel) {
@@ -732,6 +749,32 @@
openToolbarMenu(btn.dataset.menu, btn);
}
};
+ btn.addEventListener("dragstart", (e) => {
+ dragKey = btn.dataset.key; btn.classList.add("dragging");
+ try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragKey); } catch {}
+ });
+ btn.addEventListener("dragend", () => { btn.classList.remove("dragging"); clearDrop(); dragKey = null; });
+ btn.addEventListener("dragover", (e) => {
+ if (!dragKey || btn.dataset.key === dragKey) return;
+ e.preventDefault();
+ const r = btn.getBoundingClientRect(); const after = e.clientX > r.left + r.width / 2;
+ clearDrop(); btn.classList.add(after ? "dropafter" : "dropbefore");
+ });
+ btn.addEventListener("dragleave", () => btn.classList.remove("dropbefore", "dropafter"));
+ btn.addEventListener("drop", (e) => {
+ if (!dragKey || btn.dataset.key === dragKey) return;
+ e.preventDefault();
+ const r = btn.getBoundingClientRect(); const after = e.clientX > r.left + r.width / 2;
+ const from = dragKey; clearDrop(); dragKey = null;
+ T.dockMove && T.dockMove(from, btn.dataset.key, after ? "after" : "before");
+ });
+ // Right-click: native menu owned by main (open/close, move, hide, turn off…).
+ btn.addEventListener("contextmenu", (e) => {
+ e.preventDefault(); e.stopPropagation();
+ closeToolbarMenu();
+ const r = btn.getBoundingClientRect();
+ T.dockMenu && T.dockMenu(btn.dataset.key, { x: r.left, y: r.bottom });
+ });
});
// Collapsed dock button: show the first button's icon (falling back
// to 🧩 if nothing is registered). Clicking it opens a dropdown of
@@ -892,6 +935,13 @@
// adds an extension, or a wider-icon extension gets registered.
const extButtonsEl = $("extbuttons");
const extDockEl = $("extdock");
+ // Right-click on the dock's empty space (or the collapsed button): the
+ // same menu without a per-button section — "Show hidden" + "Manage".
+ extDockEl.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ closeToolbarMenu();
+ T.dockMenu && T.dockMenu(null, { x: e.clientX, y: e.clientY });
+ });
// Cache the natural width of the buttons row while expanded. When the dock
// is currently collapsed, #extbuttons has display:none so scrollWidth is
// 0 — measuring it then would falsely decide "plenty of room" and expand,
diff --git a/main.js b/main.js
index 66ff328b..c0099cca 100644
--- a/main.js
+++ b/main.js
@@ -346,6 +346,11 @@ const SETTINGS_DEFAULTS = {
// Add-on framework: ids the user has explicitly turned off. Installed but
// disabled add-ons are still discovered — they just never activate.
disabledAddons: [],
+ // Toolbar extension dock: user-chosen button order (keys "p:" /
+ // "m:", unknown keys keep registration order after these) and
+ // buttons hidden from the toolbar via the dock's right-click menu.
+ dockOrder: [],
+ dockHidden: [],
// Sidebar width in px. Adjusted by dragging the grip on the panel's left
// edge; persisted across launches. Clamped to [200, 800] on load.
sidebarWidth: 340,
@@ -2005,6 +2010,15 @@ function initAddons() {
},
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
emitToPanel: (addonId, msg, payload) => {
+ // Full-tab pages of the same add-on hear it too. addon-tab-preload has
+ // always exposed silentmode.on(), but nothing ever delivered to a tab,
+ // so an add-on had no way to tell its own open editor anything — which
+ // is what a second document needs in order to land in the editor that
+ // is already up instead of a new tab.
+ for (const t of tabs) {
+ if (t.addonId !== addonId) continue;
+ try { t.view?.webContents?.send("addon-event", msg, payload); } catch {}
+ }
if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return;
try { sidebar.webContents.send("addon-event", msg, payload); } catch {}
},
@@ -2422,6 +2436,23 @@ function showLinkStatus(url) {
// Open (or close) the sidebar. Loading the panel HTML is lazy — the first
// open triggers loadFile; subsequent opens just flip visibility.
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.
+function sidebarStatePayload() {
+ return {
+ visible: sidebarVisible,
+ active: sidebarActivePanelId,
+ panels: addonHost ? addonHost.getSidebarPanels() : [],
+ toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [],
+ dock: {
+ order: Array.isArray(settings.dockOrder) ? settings.dockOrder : [],
+ hidden: Array.isArray(settings.dockHidden) ? settings.dockHidden : [],
+ },
+ };
+}
+function emitSidebarState() {
+ try { chrome?.webContents.send("sidebar-state", sidebarStatePayload()); } catch {}
+}
function setSidebar(show, panelId) {
if (!sidebar) return;
const panels = addonHost ? addonHost.getSidebarPanels() : [];
@@ -2442,13 +2473,13 @@ function setSidebar(show, panelId) {
try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {}
layout();
try { sidebar.webContents.send("sidebar-visibility", true); } catch {}
- try { chrome?.webContents.send("sidebar-state", { visible: true, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {}
+ emitSidebarState();
} else {
sidebarVisible = false;
sidebar.setVisible(false);
layout();
try { sidebar.webContents.send("sidebar-visibility", false); } catch {}
- try { chrome?.webContents.send("sidebar-state", { visible: false, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {}
+ emitSidebarState();
}
}
function showPwFill(show, matches) {
@@ -3602,12 +3633,83 @@ 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);
-ipcMain.handle("sidebar-state", () => ({
- visible: sidebarVisible,
- active: sidebarActivePanelId,
- panels: addonHost ? addonHost.getSidebarPanels() : [],
- toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [],
-}));
+ipcMain.handle("sidebar-state", () => sidebarStatePayload());
+// ---- extension dock: reorder by drag, right-click menu --------------------
+// Keys match the chrome's: "p:" for a sidebar panel button,
+// "m:" for a toolbar-menu button. The full key list in the order
+// the chrome would draw it (prefs applied) is computed here so a move is
+// resolved against what the user actually sees.
+function dockKeys() {
+ const keys = [];
+ if (addonHost) {
+ for (const p of addonHost.getSidebarPanels()) 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 : [];
+ const rank = (k) => { const i = order.indexOf(k); return i < 0 ? order.length : i; };
+ return keys.map((k, i) => ({ k, i })).sort((a, b) => rank(a.k) - rank(b.k) || a.i - b.i).map((x) => x.k);
+}
+function dockLabel(key) {
+ if (!addonHost) return key;
+ if (key.startsWith("p:")) { const p = addonHost.getSidebarPanels().find((x) => "p:" + x.panelId === key); return p ? (p.addonName || p.title || key) : key.slice(2); }
+ const m = addonHost.getToolbarMenus().find((x) => "m:" + x.addonId === key);
+ return m ? (m.title || m.addonId) : key.slice(2);
+}
+const dockAddonId = (key) => key.startsWith("p:") ? key.slice(2).split(":")[0] : key.slice(2);
+function dockMove(key, targetKey, place) {
+ const keys = dockKeys();
+ if (!keys.includes(key) || !keys.includes(targetKey) || key === targetKey) return false;
+ const rest = keys.filter((k) => k !== key);
+ const at = rest.indexOf(targetKey) + (place === "after" ? 1 : 0);
+ rest.splice(at, 0, key);
+ settings.dockOrder = rest;
+ saveSettings();
+ emitSidebarState();
+ return true;
+}
+function dockSetHidden(key, hide) {
+ const hidden = new Set(Array.isArray(settings.dockHidden) ? settings.dockHidden : []);
+ if (hide) hidden.add(key); else hidden.delete(key);
+ settings.dockHidden = [...hidden];
+ saveSettings();
+ emitSidebarState();
+}
+ipcMain.handle("dock-move", (e, key, targetKey, place) => {
+ if (chrome && e.sender !== chrome.webContents) throw new Error("dock-move: untrusted sender");
+ if (typeof key !== "string" || typeof targetKey !== "string") return false;
+ return dockMove(key, targetKey, place === "after" ? "after" : "before");
+});
+ipcMain.handle("dock-menu", (e, key, rect) => {
+ if (chrome && e.sender !== chrome.webContents) throw new Error("dock-menu: untrusted sender");
+ const keys = dockKeys();
+ const visible = keys.filter((k) => !(settings.dockHidden || []).includes(k));
+ const hiddenKeys = (settings.dockHidden || []).filter((k) => keys.includes(k));
+ const template = [];
+ if (typeof key === "string" && keys.includes(key)) {
+ const name = dockLabel(key);
+ const pos = visible.indexOf(key);
+ if (key.startsWith("p:")) {
+ const pid = key.slice(2);
+ const open = sidebarVisible && sidebarActivePanelId === pid;
+ template.push({ label: open ? `Close ${name}` : `Open ${name}`, click: () => setSidebar(!open, pid) });
+ }
+ template.push({ type: "separator" });
+ template.push({ label: "Move left", enabled: pos > 0, click: () => dockMove(key, visible[pos - 1], "before") });
+ template.push({ label: "Move right", enabled: pos >= 0 && pos < visible.length - 1, click: () => dockMove(key, visible[pos + 1], "after") });
+ template.push({ type: "separator" });
+ template.push({ label: "Hide from toolbar", click: () => dockSetHidden(key, true) });
+ template.push({ label: `Turn off ${name}`, click: () => setAddonEnabled(dockAddonId(key), false) });
+ template.push({ type: "separator" });
+ }
+ if (hiddenKeys.length) {
+ template.push({ label: "Show hidden", submenu: hiddenKeys.map((k) => ({ label: dockLabel(k), click: () => dockSetHidden(k, false) })) });
+ }
+ template.push({ label: "Manage extensions…", click: () => openSettingsTab("addons") });
+ const popup = Menu.buildFromTemplate(template);
+ const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
+ popup.popup({ window: win, x: Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))), y: Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))) });
+ return true;
+});
// Toolbar-menu click: chrome sends the {addonId, itemId} of the item the
// user picked. Route to the add-on's registered "menu-select" handler. We
// trust chrome as the sender (same convention as sidebar-toggle et al) —
@@ -3687,6 +3789,19 @@ ipcMain.handle("addon-tab-close", (e) => {
closeTab(tab.id);
return true;
});
+// The counterpart: an add-on page asking for its own tab to be brought to
+// the front. Needed when the add-on hands an already-open page something new
+// to show and the click that caused it happened somewhere else — the sidebar,
+// say — so the result would otherwise land in a tab nobody is looking at.
+// Same confinement as the close handler: derived from the sender, so a page
+// can only front the tab it is itself in.
+ipcMain.handle("addon-tab-focus", (e) => {
+ const senderId = e.sender.id;
+ const tab = tabs.find((t) => t.view?.webContents?.id === senderId);
+ if (!tab) return false;
+ setActive(tab.id);
+ return true;
+});
// Read-side of Settings' Add-ons tab.
ipcMain.handle("addons-list", () => {
if (!addonHost) return { installed: [], sidebarPanels: [] };
@@ -3711,7 +3826,7 @@ ipcMain.handle("addons-list", () => {
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
// add-ons activate immediately and newly-disabled ones drop out — no
// restart required.
-ipcMain.handle("addons-set-enabled", (_e, id, enabled) => {
+function setAddonEnabled(id, enabled) {
if (!id || typeof id !== "string") return false;
const disabled = new Set(Array.isArray(settings.disabledAddons) ? settings.disabledAddons : []);
if (enabled) disabled.delete(id); else disabled.add(id);
@@ -3726,8 +3841,10 @@ ipcMain.handle("addons-set-enabled", (_e, id, enabled) => {
sidebarActivePanelId = null;
setSidebar(false);
}
+ emitSidebarState();
return true;
-});
+}
+ipcMain.handle("addons-set-enabled", (_e, id, enabled) => setAddonEnabled(id, enabled));
// Reveal an add-on's folder in the OS file manager — the primary way users
// edit / uninstall add-ons.
ipcMain.handle("addons-reveal", (_e, folder) => {
@@ -3800,7 +3917,7 @@ async function installCommunityById(id) {
verifyPublisher: verifyPublisherEntry,
log: (...a) => console.log("[addons]", ...a),
});
- if (r.ok && addonHost) addonHost.discoverAndActivate();
+ if (r.ok && addonHost) { addonHost.discoverAndActivate(); emitSidebarState(); }
return r;
} catch (e) { return { ok: false, error: e?.message || String(e) }; }
}
@@ -3906,6 +4023,7 @@ function listStagedAddons(stagedDir) {
ipcMain.handle("addons-reload", () => {
if (!addonHost) return false;
addonHost.discoverAndActivate();
+ emitSidebarState();
return true;
});
// --- Add-on messaging + capabilities -----------------------------------------
diff --git a/preload.js b/preload.js
index 92e4840c..715a43a6 100644
--- a/preload.js
+++ b/preload.js
@@ -81,6 +81,10 @@ contextBridge.exposeInMainWorld("theseus", {
closeSidebar: () => ipcRenderer.invoke("sidebar-close"),
sidebarState: () => ipcRenderer.invoke("sidebar-state"),
onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)),
+ // Extension dock: drag-reorder (key = "p:" | "m:") and
+ // the right-click menu (native; main owns the actions).
+ dockMove: (key, targetKey, place) => ipcRenderer.invoke("dock-move", key, targetKey, place),
+ dockMenu: (key, rect) => ipcRenderer.invoke("dock-menu", key, rect),
// Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked
// item id to the add-on's "menu-select" handler.
addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId),