feat(theseus): extension dock — drag to reorder, right-click menu

The extension buttons sat in registration order with no way to change
it, hide one, or switch an add-on off without opening Settings. Buttons
are now draggable (drop side follows the pointer, same feedback as tabs
and bookmark chips) and the order is persisted per profile. Right-click
opens a native menu: open/close the panel, move left/right, hide from
the toolbar, turn the add-on off, and Manage extensions; the dock's own
right-click offers Show hidden. Main owns the prefs (dockOrder,
dockHidden) and the actions, so the chrome only renders. Add-on
rediscovery now pushes a fresh dock state to the chrome, which it never
did before — turning an add-on off or installing one from a page
updates the toolbar at once.
This commit is contained in:
Local Dev 2026-09-22 08:30:40 +02:00
parent 70ee7fc39b
commit 6adabca85a
3 changed files with 185 additions and 13 deletions

View file

@ -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 <img> instead — same size
// slot, no layout change. Anything else is HTML-escaped and stays
// text so the emoji case keeps working.
return `<button class="extbtn${cls}" ${dataAttr} title="${b.title.replace(/"/g,"&quot;")}">${addonIconHtml(b.icon)}</button>`;
return `<button class="extbtn${cls}" ${dataAttr} data-key="${b.key.replace(/"/g,"&quot;")}" draggable="true" title="${b.title.replace(/"/g,"&quot;")}">${addonIconHtml(b.icon)}</button>`;
}).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,

140
main.js
View file

@ -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:<panelId>" /
// "m:<addonId>", 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:<panelId>" for a sidebar panel button,
// "m:<addonId>" 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 -----------------------------------------

View file

@ -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:<panelId>" | "m:<addonId>") 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),