Theseus: language menu stays short, language change always re-translates

The chip menu showed every entry in WEBSITE_LANGUAGE_QUICK (24 rows, most
greyed with "— translator coming later") and the picker read as a wall
of coming-soon noise. The top strip now carries only the languages the
translator actually handles — Automatic plus the six supported ones
(en, es, fr, de, el, ru) — and everything else moves into a "More
languages (translator coming later)" submenu where the greyed rows live
without crowding the main menu. If the user's current pin is one of the
unsupported ones, it stays visible at the top so the ✓ reads at a
glance, not two levels deep.

Translation didn't follow a language change reliably:
- A page with no `<html lang>` left pageLang empty, which the auto-
  translate hook took as "no translation needed" and skipped the entire
  page. Now an empty source is still translated (the backend auto-detects
  the real language), and the hook only skips when pageLang is known AND
  matches the user's target.
- Changing the pin via Settings or the chip reloaded the active tab but
  did not re-translate — the hook needs auto-offer on, and even then
  a `pending` latch set by setWebsiteLanguage was wiped by the reload's
  did-start-navigation reset. The `pending` bit now survives that reset,
  so the did-finish-load hook translates unconditionally for an explicit
  language switch (the user ASKING for a new language IS the request to
  translate the current page too). A translated page is reverted before
  the reload so the fresh HTML lands on original DOM, not a mix of old
  translated nodes and new content. Verified end-to-end: an es→en auto-
  translate followed by a pin to French takes the page to French in one
  shot without the chip being touched.
This commit is contained in:
Silent Mode 2026-10-03 21:24:43 +02:00
parent 61153481a6
commit a2c43d6a22

108
main.js
View file

@ -3769,10 +3769,15 @@ function createTab(initial, opts = {}) {
// Translator state is per-document: a new navigation drops any "translated"
// flag, the autoTried latch, and the cached page language. The chip then
// re-decides on the next pageLang read whether to light up — and auto-
// translate gets one more shot on the new page.
// translate gets one more shot on the new page. The one bit we preserve
// across the reset is `pending`: setWebsiteLanguage sets it right before
// triggering a reload, and the subsequent did-finish-load uses it to
// translate unconditionally (the user ASKED for a new language).
wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => {
if (!isMainFrame) return;
tab._tr = null; tab.pageLang = "";
const wasPending = !!tab._tr?.pending;
tab._tr = wasPending ? { translated: false, source: "", target: "", error: "", pending: true } : null;
tab.pageLang = "";
if (tab.id === activeId) emitTranslateState(tab);
});
// After the page has committed, read <html lang> once so the chip knows
@ -3789,16 +3794,25 @@ function createTab(initial, opts = {}) {
if (tab.id === activeId) emitTranslateState(tab);
try {
const target = translationTargetBase();
if (settings.translateAutoOffer &&
tab.pageLang && tab.pageLang !== target &&
isTranslatorSupported(tab.pageLang) && isTranslatorSupported(target) &&
!tabTranslateState(tab).translated &&
!tabTranslateState(tab).autoTried) {
tabTranslateState(tab).autoTried = true;
// Only auto-translate when this is the active tab, to spare a cold
// backend the N parallel requests of a many-tab restore; a background
// tab gets translated when the user focuses it (same hook, next load).
if (tab.id === activeId) await translateActiveTab();
const st = tabTranslateState(tab);
// Two gates fire auto-translate here:
// (a) Explicit language switch (st.pending) — the user asked for a
// different language right now; translate the active tab even if
// auto-offer is off and even if pageLang is empty (let the
// backend auto-detect the source).
// (b) Normal auto-offer — translateAutoOffer on, pageLang known and
// different from the user's target, both ends supported, once
// per document (autoTried latch).
const explicit = st.pending && tab.id === activeId;
const pageSupported = tab.pageLang ? isTranslatorSupported(tab.pageLang) : true; // unknown -> try, backend decides
const autoFire = settings.translateAutoOffer && tab.id === activeId &&
isTranslatorSupported(target) && pageSupported &&
(tab.pageLang ? tab.pageLang !== target : true) &&
!st.translated && !st.autoTried;
if (explicit || autoFire) {
st.autoTried = true;
st.pending = false;
await translateActiveTab();
}
} catch (e) { console.warn("[translate] auto failed:", e?.message); }
});
@ -4635,7 +4649,7 @@ const TRANSLATOR_SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
function isTranslatorSupported(tag) {
return TRANSLATOR_SUPPORTED.has(String(tag || "").split("-")[0].toLowerCase());
}
function setWebsiteLanguage(mode, value) {
async function setWebsiteLanguage(mode, value) {
const before = { mode: settings.languageMode, value: settings.languageValue };
if (mode === "show") {
settings.languageMode = "show";
@ -4651,11 +4665,23 @@ function setWebsiteLanguage(mode, value) {
// Nothing short of a reload makes a loaded page re-render in a new language —
// the server picked the body from the Accept-Language on the ORIGINAL request.
// The chip is a one-click language switch, so the user expects to see it take
// effect on whatever they're looking at: reload the active tab (only).
// effect on whatever they're looking at: reload the active tab (only), and
// if the page is already translated, revert first so the fresh reload lands
// on original HTML rather than a mix of translated nodes and new ones.
// After the reload lands the did-finish-load hook does the translation to
// the new target — unconditionally for an explicit language change (we
// bypass the auto-offer toggle, because the user ASKING to switch languages
// IS the request to translate the current page too).
if (changed) {
const t = activeTab();
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
try { wc && wc.reload(); } catch {}
if (wc) {
try {
if (tabTranslateState(t).translated) await wc.executeJavaScript(PAGE_TRANSLATE_REVERT).catch(() => null);
} catch {}
t._tr = { translated: false, source: "", target: "", error: "", pending: true };
try { wc.reload(); } catch {}
}
}
}
// Human-readable name for a BCP-47 tag — the plain language name, no regional
@ -4915,25 +4941,34 @@ ipcMain.handle("website-language-menu-popup", (e, rect) => {
{ type: "separator" },
);
}
// Languages the translator can actually handle appear enabled; the rest
// show but are greyed out with a "coming later" hint so the user knows the
// picker will grow, and that picking one today only sets Accept-Language.
// Top-level menu stays short: Automatic + the supported-by-the-translator
// languages only. Unsupported entries live inside a "More languages"
// submenu, greyed out — the top strip reads at a glance instead of being
// a 25-row wall. If the current pin is one of the unsupported ones, we
// still include it in the top strip so the ✓ is visible without opening
// the submenu.
const supported = WEBSITE_LANGUAGE_QUICK.filter((L) => isTranslatorSupported(L.tag));
const unsupported = WEBSITE_LANGUAGE_QUICK.filter((L) => !isTranslatorSupported(L.tag));
const pinnedUnsupported = unsupported.find((L) => current === L.tag.toLowerCase());
const topList = pinnedUnsupported ? [...supported, pinnedUnsupported] : supported;
const mkItem = (L, enabled) => {
const check = current === L.tag.toLowerCase() ? " ✓" : "";
return {
label: enabled ? `${L.label}${check}` : `${L.label}${check}`,
enabled,
click: () => setWebsiteLanguage("manual", L.tag),
};
};
const template = [
...translateItems,
{ label: `Automatic (${languageNameFor(osLoc)})${isAuto ? " ✓" : ""}`,
click: () => setWebsiteLanguage("show") },
{ type: "separator" },
...WEBSITE_LANGUAGE_QUICK.map((L) => {
const supported = isTranslatorSupported(L.tag);
const check = current === L.tag.toLowerCase() ? " ✓" : "";
return {
label: supported ? `${L.label}${check}` : `${L.label} — translator coming later${check}`,
enabled: supported,
click: () => setWebsiteLanguage("manual", L.tag),
};
}),
...topList.map((L) => mkItem(L, isTranslatorSupported(L.tag))),
{ type: "separator" },
{ label: "More languages…", click: () => { try { openSettingsTab("language"); } catch {} } },
{ label: "More languages (translator coming later)",
submenu: unsupported.map((L) => mkItem(L, false)) },
{ label: "Language settings…", click: () => { try { openSettingsTab("language"); } catch {} } },
];
const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
@ -7161,13 +7196,22 @@ ipcMain.handle("settings-set", (_e, key, val) => {
"locationMode", "locationRegion", "locationCountry", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
// Language changes alter the Accept-Language the server sees on the NEXT
// request; an already-rendered page keeps the body it was first served.
// Reload the active tab so the user sees the switch take effect without a
// manual F5. (Only for the language keys; timezone / location are JS-side
// overrides and don't need the server round-trip.)
// Reload the active tab, revert any current translation first, and set
// the "pending" bit so the did-finish-load hook translates the new page
// to the new target even if auto-offer is off — a user who just picked
// a different language expects the current page to follow.
if (key === "languageMode" || key === "languageValue") {
const t = activeTab();
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
try { wc && wc.reload(); } catch {}
if (wc) {
(async () => {
try {
if (tabTranslateState(t).translated) await wc.executeJavaScript(PAGE_TRANSLATE_REVERT).catch(() => null);
} catch {}
t._tr = { translated: false, source: "", target: "", error: "", pending: true };
try { wc.reload(); } catch {}
})();
}
}
// Quick-links strip: re-layout + push the new state to the strip's view so
// show/hide and link edits take effect without a relaunch. If the active