fix(theseus): address bar follows the page; tab strip stops blinking; extensions page

- Address bar: the chrome view keeps document.activeElement on the URL
  input after the user clicks into the page (focus moved to the tab's own
  view), and the "don't clobber typed text" guard then froze the bar until
  something blurred the field. The guard now requires real focus
  (document.hasFocus()), and the field is blurred when the view loses focus.
- Tab strip: rebuilt with innerHTML on every tabs event, which recreated
  every favicon <img> — a blink on the other tabs whenever one tab loaded,
  reloaded or changed title — and dropped drag state mid-gesture. Elements
  are now keyed by tab id (chips by group colour), updated in place, and
  moved into order; the strip is never rebuilt.
- Settings › Extensions links to theseus.x/extensions. That page now has a
  card per bundled add-on with the current signed version, tarball and
  hash read from each add-on's updates.json at load (it still claimed
  Screenshot 0.2.4 while the channel serves 0.6.5).
This commit is contained in:
Local Dev 2026-09-20 14:39:07 +02:00
parent 3d6f53e359
commit 68735ad490
2 changed files with 115 additions and 107 deletions

View file

@ -60,6 +60,8 @@
box-shadow: inset 0 2px 0 var(--acid); } box-shadow: inset 0 2px 0 var(--acid); }
.tab.active.grp { box-shadow: inset 0 2px 0 var(--gc, var(--acid)); } .tab.active.grp { box-shadow: inset 0 2px 0 var(--gc, var(--acid)); }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.tab .ico { display: flex; align-items: center; flex: none; }
.tab .ico:empty { display: none; }
.tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; } .tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; }
.tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: var(--acid); border-radius: 50%; animation: spin .7s linear infinite; } .tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: var(--acid); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@ -1159,6 +1161,10 @@
// still shows focused — belt-and-braces for cases where blur() is // still shows focused — belt-and-braces for cases where blur() is
// async and the tabs event arrives first. // async and the tabs event arrives first.
let overrideUrlBarUntil = 0; let overrideUrlBarUntil = 0;
const urlBarEditing = () => document.hasFocus() && document.activeElement === $("url");
// When focus leaves this view (the user clicked into the page or another
// window), drop the input's focus so the bar tracks the page again.
window.addEventListener("blur", () => { if (document.activeElement === $("url")) $("url").blur(); });
T.onAddressPicked && T.onAddressPicked((url) => { T.onAddressPicked && T.onAddressPicked((url) => {
try { $("url").blur(); } catch (e) {} try { $("url").blur(); } catch (e) {}
$("url").value = String(url || ""); $("url").value = String(url || "");
@ -1181,122 +1187,123 @@
const rb = $("reload"); const rb = $("reload");
if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); } if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); }
else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); } else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); }
if (document.activeElement !== $("url") || Date.now() < overrideUrlBarUntil) $("url").value = d.url || ""; // Only hold the typed text while the field really has the user's focus:
// this view keeps document.activeElement on the input after the user
// clicks into the page (focus moved to the tab's own view), and the bar
// then stopped following navigations until something blurred it.
if (!urlBarEditing() || Date.now() < overrideUrlBarUntil) $("url").value = d.url || "";
current.url = d.url || ""; current.url = d.url || "";
const active = d.tabs.find((t) => t.active); const active = d.tabs.find((t) => t.active);
current.title = active ? active.title : ""; current.title = active ? active.title : "";
current.favicon = active ? (active.favicon || null) : null; current.favicon = active ? (active.favicon || null) : null;
updateStar(); updateStar();
const box = $("tabs"); renderTabStrip($("tabs"), d);
// Render tabs with group awareness. For every group, emit a chip BEFORE });
// the group's first tab. If the group is collapsed, tabs inside it are // ---- tab strip: keyed reconciliation ------------------------------------
// hidden and the chip shows the count as "[● 3]"; expanded chips show // The strip used to be rebuilt with innerHTML on every tabs event. That
// just the color dot. // recreated every favicon <img> — a visible blink on the OTHER tabs each
// time one tab loaded, reloaded or changed title — and dropped drag/hover
// state mid-gesture. Elements are now keyed (tab id, or group colour for a
// chip), updated in place, and moved into order with insertBefore, which
// never recreates a node or reloads its image. Handlers attach once, at
// creation; they read the latest payload from lastTabsData.
let dragId = null; // shared by tab drag-reorder and group-chip drop targets
let lastTabsData = null;
function makeChip(color) {
const chip = document.createElement("button");
chip.className = "gchip g-" + color; chip.dataset.group = color; chip.dataset.key = "chip:" + color;
chip.innerHTML = `<span class="gcdot g-${color}"></span><span class="gccnt" hidden></span>`;
// collapsed chip → popover listing the group's tabs; expanded → collapse
chip.onclick = (ev) => {
ev.stopPropagation();
if (chip.classList.contains("collapsed")) openGroupPopover(chip, color, lastTabsData);
else T.tabGroupToggle && T.tabGroupToggle(color);
};
// drop target: drag any tab onto the chip to assign it to that group
chip.addEventListener("dragover", (ev) => { if (dragId == null) return; ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; chip.classList.add("droptarget"); });
chip.addEventListener("dragleave", () => chip.classList.remove("droptarget"));
chip.addEventListener("drop", (ev) => { ev.preventDefault(); chip.classList.remove("droptarget"); if (dragId != null) T.tabGroup(dragId, color); dragId = null; });
return chip;
}
function makeTab(id) {
const el = document.createElement("div");
el.className = "tab"; el.dataset.id = String(id); el.dataset.key = "tab:" + id; el.draggable = true;
el.innerHTML = `<span class="ico"></span><span class="t"></span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}"></span>`;
el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(id); else T.switchTab(id); };
// Right-click → native OS menu popped from main.js (a DOM menu forced the
// chrome view taller and opened a gap under the toolbar).
el.addEventListener("contextmenu", (e) => { e.preventDefault(); if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY }); });
// Drag-reorder — drop side chosen by pointer half (matches Chrome UX).
el.addEventListener("dragstart", (e) => { dragId = id; try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } catch {} el.classList.add("dragging"); });
el.addEventListener("dragend", () => { el.classList.remove("dragging"); el.parentElement?.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter")); dragId = null; });
el.addEventListener("dragover", (e) => {
if (dragId == null || id === dragId) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = el.getBoundingClientRect(); const before = (e.clientX - r.left) < r.width / 2;
el.classList.toggle("dropbefore", before); el.classList.toggle("dropafter", !before);
});
el.addEventListener("dragleave", () => el.classList.remove("dropbefore", "dropafter"));
el.addEventListener("drop", (e) => {
e.preventDefault();
if (dragId == null || dragId === id) return;
const r = el.getBoundingClientRect(); const before = (e.clientX - r.left) < r.width / 2;
T.moveTab(dragId, id, before ? "before" : "after");
});
return el;
}
function updateTab(el, t) {
el.classList.toggle("active", !!t.active);
el.classList.toggle("grp", !!t.group);
for (const c of [...el.classList]) if (c.startsWith("g-") && c !== "g-" + t.group) el.classList.remove(c);
if (t.group) el.classList.add("g-" + t.group);
const tt = (t.title || "New Tab") + (t.url ? "\n" + t.url : "");
if (el.title !== tt) el.title = tt;
const label = el.querySelector(".t"); const txt = t.title || "New Tab";
if (label.textContent !== txt) label.textContent = txt;
el.querySelector(".mute").hidden = !t.muted;
// Icon slot: spinner while loading, otherwise the favicon. The <img> is
// only touched when the URL actually changes, so it never reloads.
const ico = el.querySelector(".ico");
const want = t.loading ? "spin" : (t.favicon ? "fav:" + t.favicon : "none");
if (ico.dataset.state !== want) {
ico.dataset.state = want;
if (want === "spin") ico.innerHTML = '<span class="spin"></span>';
else if (want === "none") ico.replaceChildren();
else { const img = document.createElement("img"); img.className = "fav"; img.src = t.favicon; img.onerror = () => { img.remove(); }; ico.replaceChildren(img); }
}
}
function renderTabStrip(box, d) {
lastTabsData = d;
const collapsed = new Set(d.collapsedGroups || []); const collapsed = new Set(d.collapsedGroups || []);
const groupsSeen = new Set();
const groupsCount = {}; const groupsCount = {};
for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1; for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1;
box.innerHTML = d.tabs.map((t) => { const existing = new Map();
let out = ""; for (const el of box.children) if (el.dataset.key) existing.set(el.dataset.key, el);
// Chip appears once, at the FIRST tab of the group in render order. const order = [], seen = new Set();
if (t.group && !groupsSeen.has(t.group)) { for (const t of d.tabs) {
groupsSeen.add(t.group); // Chip once, before the group's first tab in render order.
if (t.group && !seen.has(t.group)) {
seen.add(t.group);
const chip = existing.get("chip:" + t.group) || makeChip(t.group);
const isCollapsed = collapsed.has(t.group); const isCollapsed = collapsed.has(t.group);
out += `<button class="gchip g-${t.group}${isCollapsed ? " collapsed" : ""}" data-group="${t.group}" title="${isCollapsed ? "Expand" : "Collapse"} ${t.group} group"><span class="gcdot g-${t.group}"></span>${isCollapsed ? `<span class="gccnt">${groupsCount[t.group]}</span>` : ""}</button>`; chip.classList.toggle("collapsed", isCollapsed);
chip.title = (isCollapsed ? "Expand " : "Collapse ") + t.group + " group";
const cnt = chip.querySelector(".gccnt");
cnt.hidden = !isCollapsed; if (isCollapsed) cnt.textContent = String(groupsCount[t.group]);
order.push(chip);
} }
// Skip rendering the tab itself if its group is collapsed. if (t.group && collapsed.has(t.group)) continue; // hidden inside a collapsed group
if (t.group && collapsed.has(t.group)) return out; const el = existing.get("tab:" + t.id) || makeTab(t.id);
// Loading spinner takes the icon slot while a page is loading, then updateTab(el, t);
// hands it back to the favicon once page-favicon-updated fires. order.push(el);
const icon = t.loading }
? '<span class="spin"></span>' let plus = document.getElementById("newtab");
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` : ''); if (!plus) { plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.onclick = () => T.newTab(); }
const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : ""; order.push(plus);
const tt = ((t.title || "New Tab") + (t.url ? "\n" + t.url : "")).replace(/"/g, "&quot;"); const keep = new Set(order);
out += `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true" title="${tt}">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span>${mute}<span class="x" data-close="${t.id}"></span></div>`; for (const el of [...box.children]) if (!keep.has(el)) el.remove();
return out; order.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); });
}).join("") + }
`<span class="newtab" id="newtab">+</span>`;
// dragId is shared between tab-reorder handlers and group-chip drop
// handlers; declared here so both scopes see the same identity.
let dragId = null;
// Group chip clicks:
// collapsed chip → show a vertical popover listing the group's tabs
// expanded chip → toggle to collapsed
// Also acts as a drop target: drag any tab onto a chip to assign it
// to that group.
box.querySelectorAll(".gchip").forEach((chip) => {
chip.onclick = (ev) => {
ev.stopPropagation();
const color = chip.dataset.group;
if (chip.classList.contains("collapsed")) {
openGroupPopover(chip, color, d);
} else {
T.tabGroupToggle && T.tabGroupToggle(color);
}
};
chip.addEventListener("dragover", (ev) => {
if (dragId == null) return;
ev.preventDefault(); ev.dataTransfer.dropEffect = "move";
chip.classList.add("droptarget");
});
chip.addEventListener("dragleave", () => chip.classList.remove("droptarget"));
chip.addEventListener("drop", (ev) => {
ev.preventDefault();
chip.classList.remove("droptarget");
if (dragId != null) T.tabGroup(dragId, chip.dataset.group);
dragId = null;
});
});
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
else T.switchTab(Number(el.dataset.id));
});
// 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);
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).
// dragId already declared above so group-chip drop targets share it.
box.querySelectorAll(".tab").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragId = Number(row.dataset.id);
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(dragId)); } catch {}
row.classList.add("dragging");
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
box.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter"));
dragId = null;
});
row.addEventListener("dragover", (e) => {
if (dragId == null || Number(row.dataset.id) === dragId) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
row.classList.toggle("dropbefore", before);
row.classList.toggle("dropafter", !before);
});
row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter"));
row.addEventListener("drop", (e) => {
e.preventDefault();
const targetId = Number(row.dataset.id);
if (dragId == null || dragId === targetId) return;
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
T.moveTab(dragId, targetId, before ? "before" : "after");
});
});
$("newtab").onclick = () => T.newTab();
});
// ---- Tab context menu (right-click a tab) ---- // ---- Tab context menu (right-click a tab) ----
const TAB_GROUP_COLORS = [ const TAB_GROUP_COLORS = [
@ -1432,7 +1439,7 @@
// ---- provenance ---- // ---- provenance ----
T.onNav((d) => { T.onNav((d) => {
setBadge(d); setReg(d); setBadge(d); setReg(d);
if (document.activeElement !== $("url")) { if (!urlBarEditing()) {
if (d.kind === "home") $("url").value = ""; if (d.kind === "home") $("url").value = "";
else if (d.host && !$("url").value) $("url").value = d.host; else if (d.host && !$("url").value) $("url").value = d.host;
} }

View file

@ -597,6 +597,7 @@
Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them
without losing anything the browser needs.</p> without losing anything the browser needs.</p>
<div class="row" style="justify-content:flex-end;gap:8px"> <div class="row" style="justify-content:flex-end;gap:8px">
<a class="btn" style="text-decoration:none;display:inline-flex;align-items:center" href="https://theseus.x/extensions/" target="_blank" rel="noopener" title="What ships, current signed versions, and how updates are verified">Browse extensions on theseus.x ↗</a>
<button id="addonsCheckUpdates" class="btn" type="button">Check for updates</button> <button id="addonsCheckUpdates" class="btn" type="button">Check for updates</button>
<button id="addonsReload" class="btn" type="button">Reload</button> <button id="addonsReload" class="btn" type="button">Reload</button>
<button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button> <button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button>