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); }
.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 .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 .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); } }
@ -1159,6 +1161,10 @@
// still shows focused — belt-and-braces for cases where blur() is
// async and the tabs event arrives first.
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) => {
try { $("url").blur(); } catch (e) {}
$("url").value = String(url || "");
@ -1181,122 +1187,123 @@
const rb = $("reload");
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); }
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 || "";
const active = d.tabs.find((t) => t.active);
current.title = active ? active.title : "";
current.favicon = active ? (active.favicon || null) : null;
updateStar();
const box = $("tabs");
// 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
// hidden and the chip shows the count as "[● 3]"; expanded chips show
// just the color dot.
renderTabStrip($("tabs"), d);
});
// ---- tab strip: keyed reconciliation ------------------------------------
// The strip used to be rebuilt with innerHTML on every tabs event. That
// 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 groupsSeen = new Set();
const groupsCount = {};
for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1;
box.innerHTML = d.tabs.map((t) => {
let out = "";
// Chip appears once, at the FIRST tab of the group in render order.
if (t.group && !groupsSeen.has(t.group)) {
groupsSeen.add(t.group);
const existing = new Map();
for (const el of box.children) if (el.dataset.key) existing.set(el.dataset.key, el);
const order = [], seen = new Set();
for (const t of d.tabs) {
// 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);
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)) return out;
// Loading spinner takes the icon slot while a page is loading, then
// hands it back to the favicon once page-favicon-updated fires.
const icon = t.loading
? '<span class="spin"></span>'
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` : '');
const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : "";
const tt = ((t.title || "New Tab") + (t.url ? "\n" + t.url : "")).replace(/"/g, "&quot;");
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>`;
return out;
}).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();
});
if (t.group && collapsed.has(t.group)) continue; // hidden inside a collapsed group
const el = existing.get("tab:" + t.id) || makeTab(t.id);
updateTab(el, t);
order.push(el);
}
let plus = document.getElementById("newtab");
if (!plus) { plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.onclick = () => T.newTab(); }
order.push(plus);
const keep = new Set(order);
for (const el of [...box.children]) if (!keep.has(el)) el.remove();
order.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); });
}
// ---- Tab context menu (right-click a tab) ----
const TAB_GROUP_COLORS = [
@ -1432,7 +1439,7 @@
// ---- provenance ----
T.onNav((d) => {
setBadge(d); setReg(d);
if (document.activeElement !== $("url")) {
if (!urlBarEditing()) {
if (d.kind === "home") $("url").value = "";
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
without losing anything the browser needs.</p>
<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="addonsReload" class="btn" type="button">Reload</button>
<button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button>