Auto refresh todo editor.

This commit is contained in:
2026-07-03 12:02:16 +02:00
parent 5e72b073b8
commit 75d6c4d430
2 changed files with 95 additions and 12 deletions
+69 -10
View File
@@ -3,10 +3,14 @@
// a plain read view paints first (fast, no dependency), then a minimal
// CodeMirror instance (todo.txt highlighting from the bundle) always mounts —
// lazily, off the critical path, so the bundle fetch never delays first paint.
// Autosave is debounced + saves on blur via POST /_todo. If the file is absent
// (GET /_todo → 404) the widget renders nothing and no FAB is created; the
// file is only ever created out-of-band. Mobile: a dedicated todo FAB opens
// the widget in the full-viewport Overlay (overlay.js), saving on dismiss.
// Autosave is debounced + saves on blur via POST /_todo. While the tab is
// visible the widget polls /_todo (conditional GET, ETag) and reloads when
// another device changed the file — but only while the local buffer is clean,
// so an active typist is never interrupted (last-write-wins on a real clash).
// If the file is absent (GET /_todo → 404) the widget renders nothing and no
// FAB is created; the file is only ever created out-of-band. Mobile: a
// dedicated todo FAB opens the widget in the full-viewport Overlay
// (overlay.js), saving on dismiss.
(function () {
var container = document.querySelector('[data-todo-rail]');
if (!container) return;
@@ -14,10 +18,11 @@
var TODO_URL = '/_todo';
var DEBOUNCE_MS = 800;
var RETRY_MS = 5000;
var POLL_MS = 15000; // external-change check cadence (visible tab only)
var view = null; // CM EditorView once mounted
var fallback = null; // <textarea> fallback if the bundle fails to load
var readView = null; // pre-focus read view
var readView = null; // placeholder shown until the editor mounts
var bodyEl = null; // hosts readView, then the editor
var marker = null; // "unsaved" indicator (shown on save failure)
var lastSaved = ''; // last text confirmed written to disk
@@ -26,13 +31,15 @@
var saving = false;
var loadingBundle = false;
var wantFocus = false; // user reached for the editor before it mounted
var knownETag = null; // ETag of the content currently in the buffer
fetch(TODO_URL, { credentials: 'same-origin' }).then(function (r) {
fetch(TODO_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (r) {
if (r.status === 404) return null; // no todo.txt — no widget
if (!r.ok) throw new Error('HTTP ' + r.status);
knownETag = r.headers.get('ETag');
return r.text();
}).then(function (text) {
if (typeof text === 'string') init(text);
if (typeof text === 'string') { init(text); startPolling(); }
}).catch(function () { /* fetch failed — leave the widget hidden */ });
function init(text) {
@@ -55,10 +62,10 @@
readView.className = 'todo-rail-view';
readView.tabIndex = 0;
readView.textContent = text;
// If the user reaches for the read view before the idle upgrade fires,
// remember it so the mounting editor takes focus.
// If the user reaches for the placeholder (click or keyboard tab both
// focus it — tabIndex 0) before the idle upgrade fires, mount the editor
// now and let it take focus so they can type without waiting.
readView.addEventListener('focus', focusUpgrade);
readView.addEventListener('click', focusUpgrade);
bodyEl.appendChild(readView);
container.appendChild(head);
@@ -170,6 +177,9 @@
saving = false;
if (!r.ok) throw new Error('HTTP ' + r.status);
lastSaved = text;
// Adopt the server's new ETag so our own write polls back as 304
// instead of reloading itself as an "external" change.
knownETag = r.headers.get('ETag') || knownETag;
marker.hidden = true;
if (currentText() !== text) scheduleSave(); // typed while saving
}).catch(function () {
@@ -180,6 +190,55 @@
});
}
// Replace the buffer with content another device wrote. Only ever called
// when the local buffer is clean (see poll), so nothing unsaved is lost.
function applyExternal(text, etag) {
knownETag = etag;
lastSaved = text;
if (view) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
} else if (fallback) {
fallback.value = text;
} else if (readView) {
readView.textContent = text; // editor not mounted yet — reseed the placeholder
}
marker.hidden = true;
}
// Conditional GET: 304 means unchanged (cheap), 200 means another device
// wrote the file. Skip while hidden, while our own save is in flight, or
// while the buffer is dirty — a real concurrent clash resolves last-write-
// wins when this device saves, and the other device polls the result.
function poll() {
if (document.visibilityState !== 'visible') return;
if (saving || saveTimer) return;
if (currentText() !== lastSaved) return;
fetch(TODO_URL, {
credentials: 'same-origin',
cache: 'no-store',
headers: knownETag ? { 'If-None-Match': knownETag } : {},
}).then(function (r) {
if (r.status === 304 || !r.ok) return; // unchanged, or a transient error/404
var etag = r.headers.get('ETag');
return r.text().then(function (text) {
// Re-check: the user may have started typing during the fetch.
if (!saving && !saveTimer && currentText() === lastSaved) {
applyExternal(text, etag);
}
});
}).catch(function () { /* offline blip — retry next cycle */ });
}
function startPolling() {
setInterval(poll, POLL_MS);
// Coming back to the tab/window is the moment a device switch matters
// most, so refresh immediately instead of waiting for the next tick.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') poll();
});
window.addEventListener('focus', poll);
}
// Closing the tab mid-debounce would drop the last jot; sendBeacon
// survives page teardown where fetch does not.
window.addEventListener('pagehide', function () {