File size: 9,593 Bytes
76fc93a 7a42df5 76fc93a 7a42df5 76fc93a 7a42df5 adb1aa1 7a42df5 76fc93a 50f7a20 7a42df5 50f7a20 7a42df5 50f7a20 7a42df5 adb1aa1 7a42df5 adb1aa1 50f7a20 adb1aa1 7a42df5 50f7a20 7a42df5 50f7a20 76fc93a 50f7a20 76fc93a 7a42df5 76fc93a 7a42df5 76fc93a 7a42df5 76fc93a 7a42df5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 | import { useEffect, useRef, useState } from "react";
import type { Editor as TiptapEditor } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import { Cloud, CloudOff, AlertTriangle, Loader2 } from "lucide-react";
import { Tooltip } from "./Tooltip";
/**
* Server-side persistence pipeline state, as returned by
* `GET /api/storage/status`. Mirrors the shape of `StorageStatus`
* in `backend/src/hf-storage.ts` - if you add a field there, add
* it here too.
*/
interface StorageStatus {
enabled: boolean;
datasetId: string;
datasetReady: boolean;
lastLocalSaveAt: number | null;
lastCloudPushAt: number | null;
pendingPush: boolean;
lastError: {
stage: "dataset-create" | "local-save" | "cloud-push";
message: string;
statusCode?: number;
at: number;
docName?: string;
} | null;
}
/**
* What the user sees in the top bar, derived from BOTH the WS
* connection state AND the server-side persistence pipeline.
*
* Severity ordering (worst first): error > offline > pending > saved.
* The displayed status is always the worst applicable signal, so
* a green "Saved" never wins over a red "Sync failed".
*/
type DisplayStatus =
| "saved" // WS connected + dataset ready + no error + recent push
| "pending" // edit in flight or push timer armed
| "offline" // WS disconnected (network or container restart)
| "error"; // backend reports lastError in the pipeline
interface Props {
editorInstance: TiptapEditor | null;
providerRef: { current: HocuspocusProvider | null };
}
const POLL_MS = 5_000;
export function SyncIndicator({ editorInstance: _editorInstance, providerRef }: Props) {
const [wsConnected, setWsConnected] = useState(false);
const [hasLocalEdit, setHasLocalEdit] = useState(false);
const [serverStatus, setServerStatus] = useState<StorageStatus | null>(null);
const editTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ---- WS layer: connection + edit activity ------------------------------
// Same lazy-provider polling pattern as the previous version: the
// provider is created by <Editor> AFTER this component mounts, so a
// useEffect([providerRef]) alone would never re-fire when it lands.
useEffect(() => {
let pollId: ReturnType<typeof setInterval> | null = null;
const attach = (p: HocuspocusProvider) => {
const onConnect = () => setWsConnected(true);
const onDisconnect = () => setWsConnected(false);
const onSynced = () => setWsConnected(true);
p.on("connect", onConnect);
p.on("disconnect", onDisconnect);
p.on("synced", onSynced);
// Seed + 1s reconcile loop (some HF proxies eat the first
// `connect` event; without this we'd stay "Offline" forever).
const seed = () => {
const ws = (p as any).configuration?.websocketProvider;
setWsConnected(ws?.status === "connected");
};
seed();
const reconcile = setInterval(seed, 1000);
// Listen at the Yjs layer so we see EVERY change (TipTap's
// own `update` event misses hero/settings/citation edits
// because those bypass prosemirror).
const ydoc = (p as any).document as
| { on: Function; off: Function }
| undefined;
const onYUpdate = (_u: Uint8Array, origin: unknown) => {
if (origin === p) return; // remote update, not ours
setHasLocalEdit(true);
if (editTimerRef.current) clearTimeout(editTimerRef.current);
// Local edit "settles" 1.5s after the last keystroke; this
// is also roughly when the backend's `debouncedSave` fires
// (2s), so the indicator briefly flashes pending then
// recovers to saved/error based on the next poll.
editTimerRef.current = setTimeout(() => setHasLocalEdit(false), 1500);
};
ydoc?.on?.("update", onYUpdate);
return () => {
p.off("connect", onConnect);
p.off("disconnect", onDisconnect);
p.off("synced", onSynced);
ydoc?.off?.("update", onYUpdate);
clearInterval(reconcile);
};
};
let cleanup: (() => void) | null = null;
if (providerRef.current) {
cleanup = attach(providerRef.current);
} else {
pollId = setInterval(() => {
const p = providerRef.current;
if (!p) return;
if (pollId) {
clearInterval(pollId);
pollId = null;
}
cleanup = attach(p);
}, 100);
}
return () => {
if (pollId) clearInterval(pollId);
if (cleanup) cleanup();
};
}, [providerRef]);
// ---- Server pipeline polling -------------------------------------------
// Cheap GET every 5s. The backend tracker updates in-process on
// every save/push/error so the worst-case latency for surfacing
// a problem is one poll interval. We don't use SSE/WS for this
// because the data is tiny, the polling interval is generous,
// and adding another long-lived connection is more failure modes
// than it's worth.
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const res = await fetch("/api/storage/status", {
credentials: "include",
});
if (cancelled) return;
if (res.ok) {
const data = (await res.json()) as StorageStatus;
setServerStatus(data);
} else if (res.status === 403) {
// Viewer (not an editor) - storage status isn't relevant
// to them. Stop polling.
return;
}
} catch {
// Network blip - keep trying. The WS disconnection will
// dominate the UI anyway in that case.
}
if (!cancelled) {
timer = setTimeout(poll, POLL_MS);
}
};
poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, []);
// ---- Derive the displayed status ---------------------------------------
// Worst-applicable wins (see DisplayStatus jsdoc).
const status: DisplayStatus = (() => {
if (serverStatus?.lastError) return "error";
if (!wsConnected) return "offline";
if (hasLocalEdit || serverStatus?.pendingPush) return "pending";
return "saved";
})();
// ---- beforeunload guard ------------------------------------------------
// If there's an unsynced local edit OR a pending push OR a known
// sync error, browsers should pop the standard "Leave site?"
// confirmation. The exact message is ignored by modern browsers
// (Chrome/Safari/Firefox show their own generic copy) but
// setting `returnValue` is what triggers the prompt.
useEffect(() => {
const needsGuard = status === "pending" || status === "error" || status === "offline";
if (!needsGuard) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Legacy browsers (and TS types still hold this) want a string.
e.returnValue = "";
return "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [status]);
// ---- Render ------------------------------------------------------------
const { icon, label, tooltip } = renderState(status, serverStatus);
return (
<Tooltip title={tooltip}>
<span
className={`sync-indicator sync-indicator--${status}`}
role="status"
aria-live="polite"
>
{icon}
<span className="sync-indicator__label">{label}</span>
</span>
</Tooltip>
);
}
function renderState(status: DisplayStatus, server: StorageStatus | null) {
switch (status) {
case "error": {
const err = server?.lastError;
const stageLabel: Record<string, string> = {
"dataset-create": "Cloud storage setup failed",
"local-save": "Local save failed",
"cloud-push": "Cloud sync failed",
};
const label = err ? stageLabel[err.stage] ?? "Storage error" : "Storage error";
const hint = err?.statusCode === 403
? " - your OAuth grant may be missing the `manage-repos` scope. Sign out and back in."
: "";
const tooltip = err
? `${label}: ${err.message}${hint}`
: "Storage error";
return {
icon: <AlertTriangle size={14} />,
label,
tooltip,
};
}
case "offline":
return {
icon: <CloudOff size={14} />,
label: "Offline",
tooltip: "Disconnected - reconnecting...",
};
case "pending":
return {
icon: <Loader2 size={14} className="spin" />,
label: "Saving...",
tooltip: server?.datasetReady === false
? "Saving locally - cloud sync starts after first successful dataset creation"
: "Saving to cloud...",
};
case "saved":
default: {
const last = server?.lastCloudPushAt;
const tooltip = last
? `All changes saved · last cloud sync ${formatRelative(last)}`
: server?.datasetReady
? "All changes saved"
: "Saved locally - cloud sync will start on first change";
return {
icon: <Cloud size={14} />,
label: "Saved",
tooltip,
};
}
}
}
function formatRelative(ts: number): string {
const diff = Date.now() - ts;
if (diff < 5_000) return "just now";
if (diff < 60_000) return `${Math.round(diff / 1000)}s ago`;
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}min ago`;
return new Date(ts).toLocaleTimeString();
}
|