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();
}