From 11ffb91ad6d50562fd252115a336b75fe22e8837 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 9 Sep 2026 02:05:46 +0200 Subject: [PATCH] fix(theseus): tab context menu goes native + bigger uniform bookmark chips with title-only labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two visible fixes from the same 2026-09-09 screenshot: Right-click a tab was building a DOM menu and then growing the chrome view height so it would fit under the tabstrip. That opened a visible gap between the toolbar and the tab body while the menu was up. Now the tab context menu goes through a new IPC "tab-context-menu-popup" (main.js) that pops an OS-native Menu at the click point, floating above every WebContentsView — no layout change, no gap. Preload exposes tabContextMenuPopup(id, {x, y}); chrome.html's tab contextmenu handler now calls it directly and the DOM openTabContextMenu / openGroupSubmenu / growChromeForMenu path is bypassed for tabs. (The bookmark bar's own ctxmenu still uses the DOM path — its short 2-3-row menus don't grow chrome enough to be visible.) Bookmark chips were too small (130px max-width, 11px text, 22px row). Now every chip is a fixed 150px × 28px cell so the row reads as a uniform grid, 12.5px text, 14px favicon. Labels drop the descriptor: "GAME.X — Bitcoin Cash game platform" renders as "GAME.X". The trimmer splits on the first em-dash / en-dash / hyphen that's surrounded by whitespace, so single-word titles and hyphenated compound names ("Foo-Bar" with no spaces) come through intact. Full title still shows on hover. --- chrome.html | 45 +++++++++++++++++-------- main.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- preload.js | 1 + 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/chrome.html b/chrome.html index e793e961..9aae14a7 100644 --- a/chrome.html +++ b/chrome.html @@ -297,10 +297,10 @@ padding: 6px 12px; border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); border-radius: 8px; cursor: pointer; background: rgb(from var(--acid) r g b / .08); font-size: 13px; } .logo:hover { background: rgb(from var(--acid) r g b / .16); } .logo .gear { font-size: 12px; opacity: .8; } - /* favorites bar — new-tab page only, single compact row. Chips are - tight so many fit in the row; the whole strip is 22px tall (down - from 26) so the chrome view gains a bit of vertical space. */ - .bookmarks { display: flex; align-items: center; gap: 3px; padding: 2px 10px 4px; height: 22px; overflow: hidden; } + /* favorites bar — new-tab page only, single compact row. Chips are a + uniform fixed width so the row reads as a neat grid regardless of the + underlying title length; the strip is 28px tall to fit the roomier chips. */ + .bookmarks { display: flex; align-items: center; gap: 6px; padding: 4px 10px 6px; height: 28px; overflow: hidden; } /* Ctrl+F find-in-page bar. Sits below the address bar (above tabs body), ~32px tall so it's part of the chrome view height like .tordisc / .bcnrbar. syncHeight in the tabs handler picks it up automatically. */ @@ -313,14 +313,17 @@ .findbar .findbtn { border: 1px solid var(--line); background: var(--surface2); color: var(--ink); border-radius: 6px; padding: 3px 8px; font-size: 13px; cursor: pointer; } .findbar .findbtn:hover { background: var(--surface); border-color: color-mix(in srgb, var(--acid) 35%, transparent); } - .bm { display: flex; align-items: center; gap: 5px; max-width: 130px; padding: 2px 7px; border-radius: 5px; - background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 11px; cursor: grab; white-space: nowrap; } + /* Fixed width (not max-width) so every chip is the same size — the row + reads as a grid. Title-only labels keep this width workable; renderer + trims descriptors after " — ". */ + .bm { display: flex; align-items: center; gap: 6px; width: 150px; flex: 0 0 150px; padding: 4px 10px; border-radius: 6px; + background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12.5px; cursor: grab; white-space: nowrap; } .bm:hover { background: var(--active); color: var(--ink); } .bm.dragging { opacity: .45; cursor: grabbing; } .bm.dropbefore { box-shadow: -2px 0 0 var(--acid); } .bm.dropafter { box-shadow: 2px 0 0 var(--acid); } - .bm .bmfav { width: 12px; height: 12px; flex: none; object-fit: contain; border-radius: 2px; } - .bm .bt { overflow: hidden; text-overflow: ellipsis; max-width: 96px; } + .bm .bmfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; } + .bm .bt { overflow: hidden; text-overflow: ellipsis; flex: 1; } /* In-chrome prompt modal (window.prompt is disabled in Electron BrowserViews, so bookmark rename etc. use this instead). */ .promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 10000; @@ -802,6 +805,17 @@ // ---- favorites bar (new-tab page only) ---- let current = { url: "", title: "", favicon: null }, bookmarks = []; + // Site titles often end with a descriptor after " — " or " - ", e.g. + // "GAME.X — Bitcoin Cash game platform". The chip only has room for the + // leading brand token, so trim at the first em-dash / en-dash / hyphen + // that's surrounded by whitespace. The full title still shows on hover + // (tooltip below) so the descriptor isn't actually lost. + function bookmarkShortLabel(title, url) { + const src = (title || url || "").trim(); + if (!src) return ""; + const cut = src.split(/\s+[—–-]\s+/, 1)[0]; + return cut || src; + } function renderBookmarks() { const box = $("bookmarks"); box.innerHTML = bookmarks.length @@ -809,7 +823,8 @@ const fav = b.favicon ? `` : ``; - return `
${fav}${(b.title||b.url).replace(/✕
`; + const short = bookmarkShortLabel(b.title, b.url); + return `
${fav}${short.replace(/✕
`; }).join("") : `No saved pages yet — click ☆ or right-click here to add one.`; box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => { @@ -1161,14 +1176,16 @@ if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close)); else T.switchTab(Number(el.dataset.id)); }); - // Right-click a tab → floating menu (Reload / Duplicate / Group / Add to - // Bookmarks / Mute / Close). Menu closes on any other click. + // Right-click a tab → native OS menu (Reload / Duplicate / Group / Add + // to Bookmarks / Mute / Close), popped from main.js. Doing this in the + // DOM used to force the chrome view taller than its natural height so + // the menu was visible below the tabstrip — that opened a visible gap + // between the toolbar and the tab body. A native Menu.popup sits above + // every WebContentsView so nothing has to grow. box.querySelectorAll(".tab").forEach((el) => el.addEventListener("contextmenu", (e) => { e.preventDefault(); const id = Number(el.dataset.id); - const t = d.tabs.find((x) => x.id === id); - if (!t) return; - openTabContextMenu(e.clientX, e.clientY, t); + if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY }); })); // Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer // is on the left or right half of the target tab (matches Chrome UX). diff --git a/main.js b/main.js index f397d77d..65309d42 100644 --- a/main.js +++ b/main.js @@ -1521,6 +1521,19 @@ function initAddons() { hostRequire: (name) => require(name), hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href), openTab: (url) => { if (win) createTab(url); }, + // openSettings: routes through the existing "open-settings" IPC handler + // so the same section-hint validation applies. Add-ons hit this when they + // want to point users at Passwords, Extensions, etc. + openSettings: (section) => { + const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : ""; + const ex = tabs.find((t) => t.settings); + if (ex) { + setActive(ex.id); + if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} } + return; + } + createTab(null, { settings: true, settingsSection: slug }); + }, // open-tab (addon-file variant): open one of the add-on's OWN files in a // full tab. The path is joined against the resolved add-on folder and // rejected if the result escapes it — belt-and-braces with the sanity @@ -2429,7 +2442,8 @@ function createTab(initial, opts = {}) { else setActive(id); if (opts.settings) { tab.prov = { host: "", kind: "home" }; - wc.loadFile("settings.html"); + const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined; + wc.loadFile("settings.html", settingsOpts); if (id === activeId) pushNav(tab.prov); emitTabs(); } else if (opts.addonFile) { @@ -2725,6 +2739,70 @@ ipcMain.handle("tab-bookmark", (_e, id) => { saveBookmarks(); emitBookmarks(); return true; }); +// Native tab context-menu popup. Anchored at the (chrome-view-relative) +// point the renderer sends. Prevents the visible "chrome view expands to +// hold a DOM menu" gap between the tabs and the tab content — the OS-owned +// popup floats above every WebContentsView and doesn't affect layout at +// all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) / +// Add to Bookmarks / Mute / Close. +ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => { + if (chrome && e.sender !== chrome.webContents) throw new Error("tab-context-menu-popup: untrusted sender"); + const t = tabById(tabId); + if (!t) return false; + const colorLabels = [ + { id: "red", label: "Red" }, { id: "orange", label: "Orange" }, + { id: "yellow", label: "Yellow" }, { id: "green", label: "Green" }, + { id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" }, + { id: "purple", label: "Purple" }, + ]; + const groupSubmenu = [ + { label: "None" + (t.group ? "" : " ✓"), click: () => { t.group = null; emitTabs(); } }, + { type: "separator" }, + ...colorLabels.map((c) => ({ + label: c.label + (t.group === c.id ? " ✓" : ""), + click: () => { + t.group = c.id; + // Cluster tabs of the same colour, matching the "tab-group" IPC. + const idx = tabs.indexOf(t); + let insertAfter = -1; + for (let i = 0; i < tabs.length; i++) { + if (i !== idx && tabs[i].group === c.id) insertAfter = i; + } + if (insertAfter !== -1) { + tabs.splice(idx, 1); + const dst = insertAfter > idx ? insertAfter : insertAfter + 1; + tabs.splice(dst, 0, t); + } + emitTabs(); + }, + })), + ]; + const bmDisabled = !t.url; + const template = [ + { label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } }, + { label: "Duplicate", click: () => { if (t.url) createTab(t.url); else createTab(); } }, + { label: "Group", submenu: groupSubmenu }, + { label: "Add to Bookmarks", enabled: !bmDisabled, click: () => { + if (bmDisabled) return; + const url = t.url, title = t.title || url; + if (!bookmarks.some((b) => b.url === url)) { + bookmarks.unshift({ url, title, addedAt: Date.now() }); + saveBookmarks(); emitBookmarks(); + } + } }, + { label: t.muted ? "Unmute" : "Mute", click: () => { + try { t.view.webContents.setAudioMuted(!t.muted); t.muted = !t.muted; emitTabs(); } catch {} + } }, + { type: "separator" }, + { label: "Close", click: () => closeTab(t.id) }, + ]; + const popup = Menu.buildFromTemplate(template); + const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 }; + const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))); + const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))); + popup.popup({ window: win, x, y }); + return true; +}); ipcMain.handle("move-tab", (_e, id, targetId, place) => { const src = tabs.findIndex((t) => t.id === id); const dst = tabs.findIndex((t) => t.id === targetId); @@ -3268,7 +3346,19 @@ ipcMain.handle("reload", (_e, hard) => { }); ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); }); ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); }); -ipcMain.handle("open-settings", () => { const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); }); +ipcMain.handle("open-settings", (_e, section) => { + // Optional section slug ("passwords", "addons", …) lands directly on + // that sidebar entry when the page loads. settings.html watches for a + // fragment on load and an IPC message when already loaded. + const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : ""; + const ex = tabs.find((t) => t.settings); + if (ex) { + setActive(ex.id); + if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} } + return; + } + createTab(null, { settings: true, settingsSection: slug }); +}); ipcMain.handle("toggle-site-info", (_e, rect) => { if (popVisible) return showPopover(false); if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; diff --git a/package.json b/package.json index 9a17f7e8..5ce7c41d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.40", + "version": "0.3.41", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", "main": "main.js", diff --git a/preload.js b/preload.js index 782bee8b..387480cc 100644 --- a/preload.js +++ b/preload.js @@ -86,6 +86,7 @@ contextBridge.exposeInMainWorld("theseus", { // then covered by the tab view below it — a native Menu.popup escapes that // layering entirely. Pass the button's viewport-rect so main can anchor. toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect), + tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, rect), // Chrome listens so it can drop the "active" tint when the native menu // dismisses (Esc, outside click, item picked — main fires all three). onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),