| 1 | import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { app, onSessionRecoveryFailed } from "../lib/bridge"; |
| 3 | import { useT } from "../lib/i18n"; |
| 4 | import type { ProjectTopicKey } from "../lib/sessionCatalogTypes"; |
| 5 | import { bindSessionVersionInspector } from "../lib/sessionRecoveryVersionHostBridge"; |
| 6 | import { normalizeRecoveryLineageView, userVisibleRecoveryVersions } from "../lib/sessionRecoveryVersions"; |
| 7 | import { useToast } from "../lib/toast"; |
| 8 | import type { RecoveryLineageMember, RecoveryLineageView, SessionMeta } from "../lib/types"; |
| 9 | |
| 10 | const RecoveryLineageDialog = lazy(() => import("./RecoveryLineageDialog").then((module) => ({ default: module.RecoveryLineageDialog }))); |
| 11 | |
| 12 | type SessionVersionsState = { topic: ProjectTopicKey; view: RecoveryLineageView }; |
| 13 | |
| 14 | interface SessionRecoveryVersionsHostProps { |
| 15 | sessions?: SessionMeta[]; |
| 16 | onResumeSession: (session: SessionMeta) => Promise<void>; |
| 17 | onRecoveryCreated: () => void; |
| 18 | onLineageChanged: () => void; |
| 19 | } |
| 20 | |
| 21 | export function SessionRecoveryVersionsHost({ sessions, onResumeSession, onRecoveryCreated, onLineageChanged }: SessionRecoveryVersionsHostProps) { |
| 22 | const t = useT(); |
| 23 | const { showToast } = useToast(); |
| 24 | const [state, setState] = useState<SessionVersionsState | null>(null); |
| 25 | |
| 26 | const showVersions = useCallback(async (topic: ProjectTopicKey, initial?: RecoveryLineageView) => { |
| 27 | try { |
| 28 | const view = normalizeRecoveryLineageView(initial ?? await app.GetRecoveryLineage(topic)); |
| 29 | if (userVisibleRecoveryVersions(view).length > 1) setState({ topic, view }); |
| 30 | } catch (error) { |
| 31 | showToast(error instanceof Error ? error.message : String(error), "error"); |
| 32 | } |
| 33 | }, [showToast]); |
| 34 | |
| 35 | const runtimeCallbacks = useRef({ onRecoveryCreated, showToast, showVersions, t }); |
| 36 | useEffect(() => { |
| 37 | runtimeCallbacks.current = { onRecoveryCreated, showToast, showVersions, t }; |
| 38 | }, [onRecoveryCreated, showToast, showVersions, t]); |
| 39 | |
| 40 | useEffect(() => { |
| 41 | let stop: (() => void) | undefined; |
| 42 | let cancelled = false; |
| 43 | void import("../lib/sessionRecoveryRuntime").then(({ startSessionRecoveryRuntime }) => { |
| 44 | if (cancelled) return; |
| 45 | stop = startSessionRecoveryRuntime({ |
| 46 | onRecovered: () => runtimeCallbacks.current.onRecoveryCreated(), |
| 47 | onDiverged: (topic, view) => { |
| 48 | const current = runtimeCallbacks.current; |
| 49 | current.showToast(current.t("recovery.divergedToast"), "warn", { |
| 50 | actionLabel: current.t("recovery.inspectLineage"), |
| 51 | onAction: () => { void current.showVersions(topic, view); }, |
| 52 | }); |
| 53 | }, |
| 54 | }); |
| 55 | }); |
| 56 | return () => { |
| 57 | cancelled = true; |
| 58 | stop?.(); |
| 59 | }; |
| 60 | }, []); |
| 61 | |
| 62 | useEffect(() => onSessionRecoveryFailed((event) => { |
| 63 | showToast(event.recoveryPending ? t("recovery.failedPending") : t("recovery.failed"), "error", event.recoveryPending && event.recoveryPath && event.topicId ? { |
| 64 | actionLabel: t("recovery.retry"), |
| 65 | onAction: () => { void app.RetrySessionRecovery({ |
| 66 | scope: event.workspaceRoot ? "project" : "global", workspaceRoot: event.workspaceRoot, |
| 67 | topicId: event.topicId!, path: event.recoveryPath!, |
| 68 | }).catch((error) => showToast(error instanceof Error ? error.message : String(error), "error")); }, |
| 69 | } : undefined); |
| 70 | }), [showToast, t]); |
| 71 | |
| 72 | const inspectVersions = useCallback((session: SessionMeta, view: RecoveryLineageView) => { |
| 73 | if (!session.topicId) return; |
| 74 | void showVersions({ |
| 75 | scope: session.scope || (session.workspaceRoot ? "project" : "global"), |
| 76 | workspaceRoot: session.workspaceRoot || undefined, |
| 77 | topicId: session.topicId, |
| 78 | path: session.path, |
| 79 | }, view); |
| 80 | }, [showVersions]); |
| 81 | useEffect(() => bindSessionVersionInspector(inspectVersions), [inspectVersions]); |
| 82 | |
| 83 | const openVersion = useCallback(async (member: RecoveryLineageMember) => { |
| 84 | const topic = state?.topic; |
| 85 | if (!topic) return; |
| 86 | // A head lives inside the session's own log: selecting it moves the open |
| 87 | // tab in place, and resuming the path afterwards lands on it when closed. |
| 88 | if (member.headId && !member.selected) await app.ChooseRecoveryBranch({ ...topic, path: member.path, headId: member.headId }); |
| 89 | const known = sessions?.find((session) => session.path === member.path); |
| 90 | await onResumeSession(known ?? sessionFromVersion(topic, member)); |
| 91 | }, [onResumeSession, sessions, state?.topic]); |
| 92 | |
| 93 | if (!state) return null; |
| 94 | return ( |
| 95 | <Suspense fallback={null}> |
| 96 | <RecoveryLineageDialog |
| 97 | topic={state.topic} |
| 98 | initial={state.view} |
| 99 | onClose={() => setState(null)} |
| 100 | onChanged={(view) => { |
| 101 | setState((current) => current ? { ...current, view } : current); |
| 102 | onLineageChanged(); |
| 103 | }} |
| 104 | onOpenVersion={openVersion} |
| 105 | /> |
| 106 | </Suspense> |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | function sessionFromVersion(topic: ProjectTopicKey, member: RecoveryLineageMember): SessionMeta { |
| 111 | const activityAt = member.lastActivityAt || member.createdAt || 0; |
| 112 | return { |
| 113 | path: member.path, preview: member.preview || "", title: member.versionNote, |
| 114 | turns: member.turns, turnsState: "valid", createdAt: member.createdAt || 0, |
| 115 | lastActivityAt: activityAt, modTime: activityAt, current: false, open: member.open, |
| 116 | scope: topic.scope, workspaceRoot: topic.workspaceRoot, topicId: topic.topicId, |
| 117 | recovered: true, recoveryRole: member.role, recoveryCanonical: member.canonical, |
| 118 | }; |
| 119 | } |
| 120 |