Theseus: drag sidebar icons into any order

The strip always drew extension panels first and web apps after them,
in a fixed order. Web apps and panels now share one order (stripOrder)
that the icons can be dragged into; reordering web apps in Settings
moves them on the strip too, and a new app or extension goes to the end.
Until the strip is rearranged, Proton Mail leads, above the panels.
This commit is contained in:
Local Dev 2026-10-08 01:30:33 +02:00
parent a0e4641ba2
commit 59d1c5ad56
3 changed files with 79 additions and 8 deletions

40
main.js
View file

@ -600,6 +600,9 @@ const SETTINGS_DEFAULTS = {
// and a click in the page closes the panel; "push" moves the page aside and
// the panel stays until it is closed.
sidebarPanelMode: "overlay",
// The strip's order, web apps ("q:<id>") and extension panels ("p:<panelId>")
// mixed, as the user dragged them. Empty: Proton Mail, the panels, the rest.
stripOrder: [],
leftPanelsHidden: [],
sidebarHideOnNewTab: true,
sidebarHideOnRightPanel: true,
@ -9156,6 +9159,14 @@ ipcMain.handle("settings-set", (_e, key, val) => {
// show/hide and link edits take effect without a relaunch. If the active
// panel's link was removed, close the panel.
if (key === "sidebarPanelMode") raisePanelGrip();
if (key === "quickLinks" && Array.isArray(settings.stripOrder) && settings.stripOrder.length) {
const slots = settings.stripOrder.map((k, i) => (k.startsWith("q:") ? i : -1)).filter((i) => i >= 0);
const next = settings.stripOrder.slice();
const ids = (settings.quickLinks || []).map((L) => "q:" + L.id);
slots.forEach((slot, n) => { next[slot] = ids[n]; });
settings.stripOrder = next.filter(Boolean);
saveSettings();
}
if (key === "quickLinksShow" || key === "quickLinks" || key === "leftPanelWidths" || key === "sidebarPanelMode" || key === "leftPanelsHidden") {
if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) {
activeQuickLinkId = null;
@ -9177,8 +9188,37 @@ 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.
// The strip, top to bottom. A saved order keeps what the user dragged; an
// item it does not know yet (a new web app or extension) goes at the end.
function stripOrder() {
const links = (settings.quickLinks || []).map((L) => "q:" + L.id);
const panels = leftPanels().filter((p) => !leftPanelHidden(p.panelId)).map((p) => "p:" + p.panelId);
const all = new Set([...links, ...panels]);
const saved = Array.isArray(settings.stripOrder) ? settings.stripOrder.filter((k) => all.has(k)) : [];
if (saved.length) {
for (const k of [...links, ...panels]) if (!saved.includes(k)) saved.push(k);
return [...new Set(saved)];
}
const top = links.includes("q:proton") ? ["q:proton"] : [];
return [...top, ...panels, ...links.filter((k) => !top.includes(k))];
}
ipcMain.handle("quicklinks-reorder", (e, order) => {
if (!quicklinks || e.sender !== quicklinks.webContents || !Array.isArray(order)) return false;
const current = stripOrder();
const want = order.map(String).filter((k, i, a) => current.includes(k) && a.indexOf(k) === i);
if (want.length !== current.length) return false;
settings.stripOrder = want;
// Settings › Sidebar lists the web apps in quickLinks order: keep it the same.
const pos = new Map(want.filter((k) => k.startsWith("q:")).map((k, i) => [k.slice(2), i]));
settings.quickLinks = (settings.quickLinks || []).slice().sort((a, b) => (pos.get(a.id) ?? 1e9) - (pos.get(b.id) ?? 1e9));
saveSettings();
emitQuickLinksState();
broadcastSettings();
return true;
});
function quickLinksPayload() {
return {
order: stripOrder(),
links: settings.quickLinks || [],
openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId,
panels: leftPanels().filter((p) => !leftPanelHidden(p.panelId)).map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })),

View file

@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("quicklinks", {
close: () => ipcRenderer.invoke("quicklinks-close"),
// The gear: rect is the button's box in the strip, for placing the menu.
menu: (rect) => ipcRenderer.invoke("quicklinks-menu", rect),
// The strip's new order after a drag: keys "q:<link id>" / "p:<panelId>".
reorder: (order) => ipcRenderer.invoke("quicklinks-reorder", order),
add: () => ipcRenderer.invoke("quicklinks-add"),
onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)),
});

View file

@ -28,6 +28,9 @@
.icon:hover { background: var(--line2); }
.icon:active { transform: scale(0.94); }
.icon.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); }
.icon.dragging { opacity: .4; }
.icon.dropbefore { box-shadow: inset 0 2px 0 var(--acid); }
.icon.dropafter { box-shadow: inset 0 -2px 0 var(--acid); }
.icon svg, .icon img { width: 20px; height: 20px; display: block; }
.icon.letter { font: 600 13px/1 system-ui, sans-serif; color: var(--mut); }
.icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%);
@ -95,12 +98,39 @@
}
// Any other site: its own favicon, and the first letter if it has none.
function letterIcon(b, L) { b.classList.add("letter"); b.prepend(document.createTextNode((L.title || "?").slice(0, 1).toUpperCase())); }
// One order for web apps and extension panels (main keeps it); drag an
// icon up or down the strip to move it.
let order = [];
let dragKey = null;
function wireDrag(b, key) {
b.draggable = true;
b.dataset.key = key;
b.addEventListener("dragstart", (e) => { dragKey = key; b.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("application/x-theseus-strip", key); } catch {} });
b.addEventListener("dragend", () => { dragKey = null; b.classList.remove("dragging"); icons.querySelectorAll(".icon").forEach((x) => x.classList.remove("dropbefore", "dropafter")); });
b.addEventListener("dragover", (e) => {
if (!dragKey || dragKey === key) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2;
b.classList.toggle("dropbefore", before); b.classList.toggle("dropafter", !before);
});
b.addEventListener("dragleave", () => b.classList.remove("dropbefore", "dropafter"));
b.addEventListener("drop", (e) => {
e.preventDefault();
if (!dragKey || dragKey === key) return;
const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2;
const next = order.filter((k) => k !== dragKey);
next.splice(next.indexOf(key) + (before ? 0 : 1), 0, dragKey);
dragKey = null;
Q && Q.reorder && Q.reorder(next);
});
}
function render(data) {
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) || [];
order = Array.isArray(data && data.order) ? data.order : [...panels.map((P) => P.id), ...links.map((L) => "q:" + L.id)];
const made = new Map();
for (const P of panels) {
const b = document.createElement("button");
b.className = "icon";
@ -119,12 +149,8 @@
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);
wireDrag(b, P.id);
made.set(P.id, b);
}
for (const L of links) {
const b = document.createElement("button");
@ -148,8 +174,11 @@
tt.className = "tt"; tt.textContent = L.title || L.url;
b.appendChild(tt);
b.onclick = () => Q && Q.open(L.id);
icons.appendChild(b);
wireDrag(b, "q:" + L.id);
made.set("q:" + L.id, b);
}
for (const k of order) { const b = made.get(k); if (b) { icons.appendChild(b); made.delete(k); } }
for (const b of made.values()) icons.appendChild(b);
}
Q && Q.getState && Q.getState().then(render).catch(() => render(null));
Q && Q.onState && Q.onState(render);