Auto refresh todo editor.
This commit is contained in:
+69
-10
@@ -3,10 +3,14 @@
|
|||||||
// a plain read view paints first (fast, no dependency), then a minimal
|
// a plain read view paints first (fast, no dependency), then a minimal
|
||||||
// CodeMirror instance (todo.txt highlighting from the bundle) always mounts —
|
// CodeMirror instance (todo.txt highlighting from the bundle) always mounts —
|
||||||
// lazily, off the critical path, so the bundle fetch never delays first paint.
|
// 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
|
// Autosave is debounced + saves on blur via POST /_todo. While the tab is
|
||||||
// (GET /_todo → 404) the widget renders nothing and no FAB is created; the
|
// visible the widget polls /_todo (conditional GET, ETag) and reloads when
|
||||||
// file is only ever created out-of-band. Mobile: a dedicated todo FAB opens
|
// another device changed the file — but only while the local buffer is clean,
|
||||||
// the widget in the full-viewport Overlay (overlay.js), saving on dismiss.
|
// 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 () {
|
(function () {
|
||||||
var container = document.querySelector('[data-todo-rail]');
|
var container = document.querySelector('[data-todo-rail]');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -14,10 +18,11 @@
|
|||||||
var TODO_URL = '/_todo';
|
var TODO_URL = '/_todo';
|
||||||
var DEBOUNCE_MS = 800;
|
var DEBOUNCE_MS = 800;
|
||||||
var RETRY_MS = 5000;
|
var RETRY_MS = 5000;
|
||||||
|
var POLL_MS = 15000; // external-change check cadence (visible tab only)
|
||||||
|
|
||||||
var view = null; // CM EditorView once mounted
|
var view = null; // CM EditorView once mounted
|
||||||
var fallback = null; // <textarea> fallback if the bundle fails to load
|
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 bodyEl = null; // hosts readView, then the editor
|
||||||
var marker = null; // "unsaved" indicator (shown on save failure)
|
var marker = null; // "unsaved" indicator (shown on save failure)
|
||||||
var lastSaved = ''; // last text confirmed written to disk
|
var lastSaved = ''; // last text confirmed written to disk
|
||||||
@@ -26,13 +31,15 @@
|
|||||||
var saving = false;
|
var saving = false;
|
||||||
var loadingBundle = false;
|
var loadingBundle = false;
|
||||||
var wantFocus = false; // user reached for the editor before it mounted
|
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.status === 404) return null; // no todo.txt — no widget
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
knownETag = r.headers.get('ETag');
|
||||||
return r.text();
|
return r.text();
|
||||||
}).then(function (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 */ });
|
}).catch(function () { /* fetch failed — leave the widget hidden */ });
|
||||||
|
|
||||||
function init(text) {
|
function init(text) {
|
||||||
@@ -55,10 +62,10 @@
|
|||||||
readView.className = 'todo-rail-view';
|
readView.className = 'todo-rail-view';
|
||||||
readView.tabIndex = 0;
|
readView.tabIndex = 0;
|
||||||
readView.textContent = text;
|
readView.textContent = text;
|
||||||
// If the user reaches for the read view before the idle upgrade fires,
|
// If the user reaches for the placeholder (click or keyboard tab both
|
||||||
// remember it so the mounting editor takes focus.
|
// 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('focus', focusUpgrade);
|
||||||
readView.addEventListener('click', focusUpgrade);
|
|
||||||
bodyEl.appendChild(readView);
|
bodyEl.appendChild(readView);
|
||||||
|
|
||||||
container.appendChild(head);
|
container.appendChild(head);
|
||||||
@@ -170,6 +177,9 @@
|
|||||||
saving = false;
|
saving = false;
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
lastSaved = text;
|
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;
|
marker.hidden = true;
|
||||||
if (currentText() !== text) scheduleSave(); // typed while saving
|
if (currentText() !== text) scheduleSave(); // typed while saving
|
||||||
}).catch(function () {
|
}).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
|
// Closing the tab mid-debounce would drop the last jot; sendBeacon
|
||||||
// survives page teardown where fetch does not.
|
// survives page teardown where fetch does not.
|
||||||
window.addEventListener('pagehide', function () {
|
window.addEventListener('pagehide', function () {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,11 +14,23 @@ import (
|
|||||||
// widget treats as "render nothing"); POST replaces the whole file atomically.
|
// widget treats as "render nothing"); POST replaces the whole file atomically.
|
||||||
// Unlike the index.md save path, empty content does NOT delete the file: the
|
// Unlike the index.md save path, empty content does NOT delete the file: the
|
||||||
// widget has no in-UI way to recreate it, so an empty save keeps an empty file.
|
// widget has no in-UI way to recreate it, so an empty save keeps an empty file.
|
||||||
|
//
|
||||||
|
// Both GET and POST carry an ETag (a content hash) so the widget can poll for
|
||||||
|
// external edits: it revalidates GET with If-None-Match and reloads only when
|
||||||
|
// another device actually changed the bytes. The POST response echoes the new
|
||||||
|
// ETag so a device's own save comes back 304 rather than reloading itself.
|
||||||
|
|
||||||
func (h *handler) todoPath() string {
|
func (h *handler) todoPath() string {
|
||||||
return filepath.Join(h.root, "todo.txt")
|
return filepath.Join(h.root, "todo.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todoETag is a strong validator derived from the file content — not mtime, so
|
||||||
|
// a save of identical bytes (or a touch) does not look like a change.
|
||||||
|
func todoETag(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return `"` + hex.EncodeToString(sum[:])[:16] + `"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) handleTodo(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleTodo(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.checkAuth(w, r) {
|
if !h.checkAuth(w, r) {
|
||||||
return
|
return
|
||||||
@@ -41,9 +55,16 @@ func (h *handler) handleTodoGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The rail re-fetches on every page load; a heuristically cached copy
|
// The rail re-fetches on every page load and polls for external edits; a
|
||||||
// would show stale todos after an edit.
|
// heuristically cached copy would show stale todos. Revalidation is driven
|
||||||
|
// by the ETag (If-None-Match) rather than the browser's own HTTP cache.
|
||||||
|
etag := todoETag(data)
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if r.Header.Get("If-None-Match") == etag {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Write(data)
|
w.Write(data)
|
||||||
}
|
}
|
||||||
@@ -58,5 +79,8 @@ func (h *handler) handleTodoPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Echo the new ETag so the saving client can adopt it and skip reloading
|
||||||
|
// its own write on the next poll.
|
||||||
|
w.Header().Set("ETag", todoETag([]byte(content)))
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user