From 2a430247e0c27067350a91c4bcda2a88a9790e9d Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 3 Sep 2026 01:47:24 +0000 Subject: [PATCH] Shared editor language selection; paced WebSocket reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LangSelect.vue: one language selector for the page and structure tabs (small flag, clean dropdown), v-modeled on the shell-wide editorLang — switching in either panel switches both AND the page preview. While the panel is open the selection overrides the normal language preferences (swapdoc.setLangOverride: loadPlain and pagerite.js fetches/prefetches pin ?lang=, the primary language by its own code); closing restores. - reconnect.js: every socket (page/banner editors, analytics view, the activity channel) now connects through a staggered slot — simultaneous attempts at page load (Vite's HMR socket plus ours, repeated on every refresh) trip the browser's WebSocket throttling, leaving all sockets to the host "pending" for minutes, which was the recurring empty editor. A watchdog closes sockets stuck CONNECTING so they reschedule through the policy (jittered exponential backoff, reset only by a healthy connection) instead of hanging forever; a reconnected editor re-opens its document when the previous open died with its socket. --- AGENTS.md | 2 + docs/editing.md | 4 + docs/localization.md | 24 ++-- frontend/src/AnalyticsView.vue | 19 +++- frontend/src/BannerEditor.vue | 29 +++-- frontend/src/EditorShell.vue | 33 +++++- frontend/src/LangSelect.vue | 133 ++++++++++++++++++++++ frontend/src/PageEditor.vue | 182 +++++++++++-------------------- frontend/src/StructureEditor.vue | 84 +++----------- frontend/src/editorLang.js | 7 ++ frontend/src/pagerite.js | 35 ++++-- frontend/src/reconnect.js | 56 ++++++++++ frontend/src/swapdoc.js | 26 ++++- 13 files changed, 411 insertions(+), 223 deletions(-) create mode 100644 frontend/src/LangSelect.vue create mode 100644 frontend/src/editorLang.js create mode 100644 frontend/src/reconnect.js diff --git a/AGENTS.md b/AGENTS.md index 2aab7d2..be95772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `main.js` — Vue editor app entry. - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). - `pagerite.js` — public page entry. + - `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`). + - `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling. - `assets/` — base CSS, Pygments styles, fonts. - `scripts/devserver.py` — dev server with auto reload (the user mostly uses this; avoid running the server yourself, ask the user to test). - `scripts/translator.py` — Seed-X translator service client for the `/_translate/{key}` socket (reference client, runs in its own uv env via PEP 723). diff --git a/docs/editing.md b/docs/editing.md index c3420dd..82ee4c5 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -15,6 +15,10 @@ Media uploads everywhere use the image icon buttons (pasting into the editor wor In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes. +The page and structure tabs share one language selector: `LangSelect.vue` (small flag + dropdown) v-modeled on the shell-wide selection in `editorLang.js` (`''` = primary). While the panel is open that selection overrides the page's normal language preferences: EditorShell calls `swapdoc.setLangOverride`, which pins every `loadPlain` fetch (`?lang=`, the primary by its own code) and pagerite.js's own fetches/prefetches (`pagerite:session-lang`), until the panel closes and the override clears. + +All WebSockets (page/banner editors, analytics view, the pagerite.js activity channel) pace their connections through `reconnect.js`: new sockets are created a staggered slot apart (a page load opens Vite's HMR socket plus several of ours at the same moment, and such bursts — like rapid retries — trip the browser's WebSocket throttling, leaving every socket to the host "pending" for minutes), a watchdog closes sockets stuck CONNECTING so they reschedule instead of hanging forever, and retries follow an exponential backoff with jitter that only a healthy connection resets. + ## Saving behavior Everything saves immediately as you edit (brand/title/CSS debounced, slug on commit since it renames the path), theme change swaps the stylesheet in place, tree rows navigate in place without transitions when focused, and the front page is a root-only row whose empty slug is editable like any other. Saves that can affect other pages drop the prefetch cache; the cache is rebuilt when the editor panel closes so navigation stays instant. diff --git a/docs/localization.md b/docs/localization.md index fa83c56..69fbb5b 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -219,16 +219,24 @@ def get_translation(data, path, lang) -> Translation | None: ### Editor flow -The page editor has a language picker (flag + name; the same -country-flag-icons set as the analytics visitor cells) listing the primary -language and the union of the page's translations (`node.langs`) and the -site-wide `translate_langs`. It always opens in the primary language, even -when the page itself was served in a translation. A note under the toolbar -states the blast radius: +The page and structure editors share one language selector (`LangSelect.vue`: +a small flag button opening a dropdown; the same country-flag-icons set as +the analytics visitor cells), v-modeled on one shell-wide selection +(`editorLang.js`, `''` = the primary language). The page editor lists the +primary language and the union of the page's translations (`node.langs`) and +the site-wide `translate_langs`; it always opens in the primary language, +even when the page itself was served in a translation. A note under the +toolbar states the blast radius: edits to the primary language re-chunk the original (invalidating the affected translation fragments everywhere); edits to a translation stay local to that language. +While the editor panel is open, its language selection **overrides the +normal language preferences** for the page preview: EditorShell pins every +in-place re-render and pagerite.js fetch/prefetch to it (`?lang=` — the +primary language pins by its own code, which `select_language` honors), and +closing the panel restores the normal preferences. + - WS `open` with a `lang` returns the effective **hybrid** Markdown and title for that language (ungated by `node.langs` — a language without any fragments yet starts from the original text), plus the language @@ -254,7 +262,9 @@ local to that language. updates `Data.chunks` / `node.chunks` — only genuinely new text lands in the kanta change diff (see docs/migrate.md). -The **structure editor** has the same flag strip for titles. The tree it +The **structure editor** selects from the same languages with the same +`LangSelect` (the selection is shared — switching in either tab switches +both, and the preview). The tree it lists (`GET /_api/pages?lang=`) comes back with per-language titles where a translation exists (`translated` marks those rows; untranslated rows show the original title, dimmed). Retitling in a non-primary language posts the diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 594eafb..ca82644 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -27,6 +27,7 @@ import VisitorCell from './VisitorCell.vue' import TransitionGraph from './TransitionGraph.vue' import VisitorCharts from './VisitorCharts.vue' import { VIEW_W } from './analytics/chart.js' +import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' // Same centering margin as the charts, so the totals row's left edge // aligns with the chart svg above the natural width. @@ -40,6 +41,8 @@ const error = ref('') const now = ref(Date.now()) let ws = null let reconnectTimeout = null +let connectWatchdog = null +const reconnects = reconnectPolicy() let timeInterval = null // The initial range comes from the URL hash (shareable links); without one, @@ -53,7 +56,12 @@ function connectAnalytics() { if (ws) return const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`) - ws.onopen = () => { error.value = '' } + clearTimeout(connectWatchdog) + connectWatchdog = watchConnecting(ws, 'analytics') + ws.onopen = () => { + reconnects.opened() + error.value = '' + } ws.onmessage = (event) => { try { data.value = JSON.parse(event.data) @@ -75,12 +83,16 @@ function connectAnalytics() { } ws.onclose = () => { ws = null - reconnectTimeout = setTimeout(connectAnalytics, 2000) + // The policy paces the retry: doubling backoff with jitter, reset only + // by a healthy connection — a fixed rapid loop trips the browser's + // WebSocket throttling (all sockets then sit "pending" for minutes). + reconnectTimeout = setTimeout(connectAnalytics, reconnects.closed()) } } onMounted(async () => { - connectAnalytics() + // The first connection takes a staggered slot (see ./reconnect). + reconnectTimeout = setTimeout(connectAnalytics, socketSlot()) now.value = Date.now() timeInterval = setInterval(() => { now.value = Date.now() }, 1000) // The site tree for the transition map (all pages in menu order). Not @@ -93,6 +105,7 @@ onMounted(async () => { onUnmounted(() => { if (reconnectTimeout) clearTimeout(reconnectTimeout) + if (connectWatchdog) clearTimeout(connectWatchdog) if (timeInterval) clearInterval(timeInterval) if (ws) { ws.onclose = null diff --git a/frontend/src/BannerEditor.vue b/frontend/src/BannerEditor.vue index cf2b6e7..56502dc 100644 --- a/frontend/src/BannerEditor.vue +++ b/frontend/src/BannerEditor.vue @@ -8,6 +8,7 @@ import { keymap } from '@codemirror/view' import { indentWithTab } from '@codemirror/commands' import { html } from '@codemirror/lang-html' import { cmHighlight, cmTheme } from './cmtheme' +import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { dropPageCache, loadPlain, runScripts } from './swapdoc' const props = defineProps({ @@ -25,8 +26,9 @@ const bannerEl = ref(null) let ws = null let pendingSave = null let reconnectTimer = null -let reconnectDelay = 2000 -const MAX_RECONNECT_DELAY = 16000 +let connectWatchdog = null +// Reconnection pacing lives in ./reconnect (shared with the other sockets). +const reconnects = reconnectPolicy() let everConnected = false let view = null // CodeMirror for the banner HTML let syncing = false // set while replacing the document programmatically @@ -234,12 +236,20 @@ function onKeydown(ev) { } function connect() { + clearTimeout(reconnectTimer) + if (ws) { + // Replacing a stale socket: detach its handlers so its close is silent. + ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null + if (ws.readyState !== WebSocket.CLOSED) ws.close() + } ws = new WebSocket( `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`, ) ws.onmessage = onMessage + clearTimeout(connectWatchdog) + connectWatchdog = watchConnecting(ws, 'banner') ws.onopen = () => { - reconnectDelay = 2000 + reconnects.opened() if (everConnected) { if (pendingSave) send(pendingSave) } else { @@ -248,16 +258,16 @@ function connect() { everConnected = true } ws.onclose = () => { - clearTimeout(reconnectTimer) - reconnectTimer = setTimeout(() => { - connect() - reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY) - }, reconnectDelay) + // The wait is the policy's: doubling backoff with jitter (./reconnect), + // reset only by a healthy connection — rapid retries trip the browser's + // WebSocket throttling (sockets stuck "pending" for minutes). + reconnectTimer = setTimeout(connect, reconnects.closed()) } } onMounted(async () => { - connect() + // The first connection takes a staggered slot (see ./reconnect). + reconnectTimer = setTimeout(connect, socketSlot()) view = new EditorView({ state: EditorState.create({ doc: '', @@ -286,6 +296,7 @@ onMounted(async () => { onUnmounted(() => { clearTimeout(reconnectTimer) + clearTimeout(connectWatchdog) for (const t of Object.values(timers)) clearTimeout(t) if (ws) { ws.onclose = null // intentional close, no reconnect diff --git a/frontend/src/EditorShell.vue b/frontend/src/EditorShell.vue index 79f83ef..b75aa11 100644 --- a/frontend/src/EditorShell.vue +++ b/frontend/src/EditorShell.vue @@ -8,6 +8,8 @@ import BannerEditor from './BannerEditor.vue' import SiteEditor from './SiteEditor.vue' import StructureEditor from './StructureEditor.vue' import LocalizationEditor from './LocalizationEditor.vue' +import { editorLang } from './editorLang' +import { loadPlain, setLangOverride } from './swapdoc' const props = defineProps({ pagePath: { type: String, default: '' }, @@ -18,6 +20,25 @@ const emit = defineEmits(['close']) const currentPath = ref(props.pagePath) const activeMode = ref(props.initialMode) +// The shared language selection (./editorLang, v-modeled by the tabs' +// LangSelects) also drives the page preview: while the shell is open it +// overrides the normal language preferences (?lang= / Accept-Language), +// so the page renders in the language being edited; closing restores. +const primaryLang = ref('en') +let pinned = false +function pinPreviewLang() { + pinned = true + setLangOverride(editorLang.value || primaryLang.value) + loadPlain(currentPath.value) +} +function unpinPreviewLang() { + if (!pinned) return + pinned = false + setLangOverride(null) + loadPlain(currentPath.value) +} +watch(editorLang, () => { if (pinned) pinPreviewLang() }) + // Tab order: site-wide settings first (site, structure, localization), then // — after a visual break — the per-page editors (article, banner). const MODES = [ @@ -62,15 +83,25 @@ function onSwitchEvent(ev) { onMounted(() => { document.body.dataset.editorMode = activeMode.value addEventListener('pagerite:switch-editor', onSwitchEvent) + addEventListener('pagerite:editor-shown', pinPreviewLang) + addEventListener('pagerite:editor-hidden', unpinPreviewLang) + // The shell mounts visible (openEditor), so pin immediately. The primary + // language's code (for pinning it explicitly) comes from the settings. + pinPreviewLang() + fetch('/_api/settings').then((r) => r.json()).then((s) => { + primaryLang.value = s.primary_lang || 'en' + }).catch(() => { /* keep the default */ }) }) onUnmounted(() => { removeEventListener('pagerite:switch-editor', onSwitchEvent) + removeEventListener('pagerite:editor-shown', pinPreviewLang) + removeEventListener('pagerite:editor-hidden', unpinPreviewLang) })