| 1 | import { useCallback, useEffect, type RefObject } from "react"; |
| 2 | |
| 3 | import type { |
| 4 | ModelInfo, |
| 5 | SessionDraftSettings, |
| 6 | SessionDraftSubmissionView, |
| 7 | SessionDraftView, |
| 8 | } from "../generated/desktopContract.generated"; |
| 9 | import { app } from "../lib/bridge"; |
| 10 | |
| 11 | export function canonicalJSON(value: unknown): string { |
| 12 | return JSON.stringify(value, (_key, item) => item && typeof item === "object" && !Array.isArray(item) |
| 13 | ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item); |
| 14 | } |
| 15 | |
| 16 | export function sameDraftSettings(left: SessionDraftSettings, right: SessionDraftSettings): boolean { |
| 17 | // An inherited model is a live projection, not an editor change. Keep this |
| 18 | // comparison aligned with the backend draft digest for saves and submission. |
| 19 | if (left.modelSource === "default" && right.modelSource === "default") { |
| 20 | return canonicalJSON({ ...left, model: "" }) === canonicalJSON({ ...right, model: "" }); |
| 21 | } |
| 22 | return canonicalJSON(left) === canonicalJSON(right); |
| 23 | } |
| 24 | |
| 25 | export function draftSubmissionLocksEditing(operation?: SessionDraftSubmissionView | null): boolean { |
| 26 | return Boolean(operation && !["cancelled", "terminal_failed"].includes(operation.phase)); |
| 27 | } |
| 28 | |
| 29 | export type InheritedDraftModelEntry = { |
| 30 | draft: SessionDraftView; |
| 31 | settings: SessionDraftSettings; |
| 32 | models?: ModelInfo[]; |
| 33 | generation: number; |
| 34 | lifecycle: "active" | "converted" | "discarded"; |
| 35 | preparingSubmission: boolean; |
| 36 | operation?: SessionDraftSubmissionView; |
| 37 | modelReadVersion: number; |
| 38 | }; |
| 39 | |
| 40 | export function applyInheritedModel(entry: InheritedDraftModelEntry, settings: SessionDraftSettings, readVersion: number) { |
| 41 | if (entry.modelReadVersion !== readVersion || entry.settings.modelSource !== "default" |
| 42 | || settings.modelSource !== "default") return; |
| 43 | entry.settings = { ...entry.settings, model: settings.model }; |
| 44 | entry.draft = { ...entry.draft, settings: { ...entry.draft.settings, model: settings.model } }; |
| 45 | } |
| 46 | |
| 47 | export function useInheritedDraftModels<T extends InheritedDraftModelEntry>( |
| 48 | entriesRef: RefObject<Map<string, T>>, |
| 49 | disposed: RefObject<boolean>, |
| 50 | publish: (draftId?: string | null) => void, |
| 51 | ) { |
| 52 | const refresh = useCallback(async () => { |
| 53 | const targets = [...entriesRef.current.values()] |
| 54 | .filter((entry) => entry.lifecycle === "active" && entry.settings.modelSource === "default" |
| 55 | && !entry.preparingSubmission && !draftSubmissionLocksEditing(entry.operation)) |
| 56 | .map((entry) => ({ id: entry.draft.id, entry, generation: entry.generation, readVersion: ++entry.modelReadVersion })); |
| 57 | await Promise.all(targets.map(async (target) => { |
| 58 | try { |
| 59 | const context = await app.GetDraftContext(target.id); |
| 60 | const entry = entriesRef.current.get(target.id); |
| 61 | if (disposed.current || !entry || entry !== target.entry || entry.lifecycle !== "active" || entry.generation !== target.generation |
| 62 | || entry.modelReadVersion !== target.readVersion || entry.settings.modelSource !== "default" |
| 63 | || context.draft.settings.modelSource !== "default" || entry.preparingSubmission |
| 64 | || draftSubmissionLocksEditing(entry.operation)) return; |
| 65 | entry.models = context.models ?? []; |
| 66 | applyInheritedModel(entry, context.draft.settings, target.readVersion); |
| 67 | publish(target.id); |
| 68 | } catch { |
| 69 | // Keep the last effective model until the next catalog refresh or open. |
| 70 | } |
| 71 | })); |
| 72 | }, [disposed, entriesRef, publish]); |
| 73 | |
| 74 | useEffect(() => { |
| 75 | const listener = () => { void refresh(); }; |
| 76 | window.addEventListener("reasonix:model-catalog-changed", listener); |
| 77 | return () => window.removeEventListener("reasonix:model-catalog-changed", listener); |
| 78 | }, [refresh]); |
| 79 | } |
| 80 |