From 8dfcc026ebecf2ae9d01fade4524bb390f8c71ed Mon Sep 17 00:00:00 2001 From: Matthew Peterkort <33436238+matthewpeterkort@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:44:39 +0000 Subject: [PATCH] refactor(loom-ui): move row-root change workflow into authoring hook [198] BuilderWorkspace owned the assess -> resolve ambiguity -> preview -> apply row-root change lifecycle inline: three request refs, an epoch ref, two state cells, the stale-abort effect, and five action callbacks declared beside unrelated workspace and publishing state (base lines 556-564, 989-1039, 2094-2365). The workflow's only UI is the RowRootPicker / RowChangePreviewPanel composition, and authoring/rootChangeSelection.ts already owns its identity comparison and discriminated UI-state types, so the lifecycle now lives in one feature-owned hook beside those types. - authoring/useRowChangeWorkflow.ts: request lifecycle, epoch/refusal protocol, stale-abort effect, and select/resolve/refresh/cancel/apply actions. Inputs are the typed request identity fields, assess/preview ports, preview limit, and narrow apply/reload/message callbacks; output is the existing discriminated state plus the actions and a render-derived readyRowChangePreview narrowing (same identity predicate as the removed rootChangePreviewReady boolean). - BuilderWorkspace keeps table selection, panel rendering, the workspace refs the actions read, and the assess mutation whose isLoading still gates publishing. No new useEffect: the single stale-abort effect moved with its body, dependency values, and registration order. - Bodies moved verbatim via a checked extraction (documented renames only in the effect/identity render-scope reads); every moved body verified byte-identical against the base blob, including the workspaceAttached.current guard line. Behavior preserved unchanged: preview-before-apply, abort/ignore-after- identity-change, NO_CHANGE/BLOCKED/stale/unavailable/request/ CLIENT_CANCELLED outcomes and messages, ambiguity validation against the current server assessment with route-rebase retention, request dispatch timing, and first-failure precedence. Validation: BuilderWorkspace.reconciliation.unit.test.tsx 92/92; core protocol + operation history + comprehensive formal 42/42; full ExplorerBuilder 79 files 1076/1076; full loom-ui package 97 files 1355/1355, 0 skipped; typecheck:tests and declaration emit clean; check-boundaries passes; no-code-comments gate 0 violations; git diff --check clean. Coordination (outside this ticket's file ownership, flagged not neutered): scripts/lib/formal-source-proofs.mjs Inv_DetachedRootChangeDoesNotPreview still anchors its workspaceAttached mutation at BuilderWorkspace.tsx and now hard-errors "Nonunique mutation anchor"; repointing only the path to authoring/useRowChangeWorkflow.ts (before/after strings unchanged) passes the full positive/broken/restored proof, so the control still detects the fault. docs/ui-contract-inventory needs its BuilderWorkspace.tsx evidence ranges re-anchored (10 distinct ranges shifted by the net -338 lines), plus regenerated watchedFiles hash/file-set. Closes https://github.com/calypr/loom/issues/198 --- .../ExplorerBuilder/BuilderWorkspace.tsx | 399 ++-------------- .../authoring/useRowChangeWorkflow.ts | 442 ++++++++++++++++++ 2 files changed, 473 insertions(+), 368 deletions(-) create mode 100644 ui/packages/loom-ui/src/features/ExplorerBuilder/authoring/useRowChangeWorkflow.ts diff --git a/ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx b/ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx index c15caa4fb..fb04351a7 100644 --- a/ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx +++ b/ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx @@ -39,7 +39,6 @@ import type { ConstructionOperation, ConstructionStep, ConstructionChoiceSelection, - RowChangeAssessment, } from '../../types'; import { BuilderToolbar, NewExplorerControl } from './components/BuilderToolbar'; import { @@ -76,15 +75,8 @@ import { import { hasCommandSelectedTable, hasPairedPreviewRows, - isCompilerNavigationSelection, } from './authoring/workspaceGuards'; -import { - sameRootChangeRequestIdentity, - type RootChangePickerState, - type RootChangeRequestIdentity, - type RootChangeResolution, - type RootChangeSelection, -} from './authoring/rootChangeSelection'; +import { useRowChangeWorkflow } from './authoring/useRowChangeWorkflow'; import { builderAuthoringReducer } from './authoring/reducer'; import { planInitialTable, @@ -369,15 +361,6 @@ type AutomaticPreviewExecution = { previewRequest?: { readonly abort: () => void }; }; -type RowChangePreviewContext = { - readonly assessment: Extract; - readonly identity: RootChangeRequestIdentity; -}; - -type PendingRowChangePreview = - | (RowChangePreviewContext & { readonly status: 'loading' }) - | (RowChangePreviewContext & { readonly status: 'ready'; readonly preview: ExplorerBuilderPreviewResult }); - type FirstTableProgress = | { readonly kind: 'idle' } | { @@ -553,15 +536,6 @@ const BuilderWorkspaceContent = ({ readonly key: string; readonly message: string; }>(); - const [rootChangeState, setRootChangeState] = - useState({ kind: 'idle' }); - const rootChangeStateRef = useRef(rootChangeState); - rootChangeStateRef.current = rootChangeState; - const [pendingRowChangePreview, setPendingRowChangePreview] = - useState(); - const rowChangeAssessmentRequest = useRef<{ readonly abort: () => void } | undefined>(undefined); - const rowChangeRequestEpoch = useRef(0); - const rowChangePreviewRequest = useRef(undefined); const [lastPublished, setLastPublished] = useState<{ readonly ownerKey: string; readonly draftDigest: string; @@ -953,19 +927,35 @@ const BuilderWorkspaceContent = ({ useDirtyBeforeUnload(state.dirty || pendingCommands > 0); const table = selectedTable(state); - const rootChangeIdentityForTable = (outputId: string): RootChangeRequestIdentity => ({ - epoch: rowChangeRequestEpoch.current, - project: projectId, - ...(authResourcePath ? { authResourcePath } : {}), + const { + applyRowChangePreview, + cancelRootChange, + deriveIdentity: rootChangeIdentityForTable, + pendingRowChangePreview, + readyRowChangePreview, + refreshRootChange, + resolveRootChange, + rootChangeState, + selectRootChoice, + } = useRowChangeWorkflow({ + applyCommands, + assessRowChange, + authResourcePath, + draftDigest: state.draftDigest, + draftVersion: state.draftVersion, explorerId: state.explorerId, - outputId, + latestState, + loomClient, + previewLimit, + projectId, + refetchBuilder, + selectedOutputId: table?.outputId, + serverDraft, + setMessage, snapshotToken: state.catalog.snapshotToken, - draftVersion: serverDraft.current.version, - draftDigest: serverDraft.current.digest, + syncBuilderData, + workspaceAttached, }); - const currentRootChangeIdentity = table - ? rootChangeIdentityForTable(table.outputId) - : undefined; const candidateForPreviewColumn = (column: ExplorerBuilderColumn): ExplorerBuilderCandidate | undefined => { const context = interpretationContext; if ( @@ -986,57 +976,6 @@ const BuilderWorkspaceContent = ({ const [candidateId] = candidateIds; return state.catalog.candidates?.find((candidate) => candidate.candidateId === candidateId); }; - useEffect(() => { - const currentIdentity: RootChangeRequestIdentity | undefined = table ? { - epoch: rowChangeRequestEpoch.current, - project: projectId, - ...(authResourcePath ? { authResourcePath } : {}), - explorerId: state.explorerId, - outputId: table.outputId, - snapshotToken: state.catalog.snapshotToken, - draftVersion: serverDraft.current.version, - draftDigest: serverDraft.current.digest, - } : undefined; - const active = rootChangeStateRef.current; - const pendingPreview = pendingRowChangePreview; - const activeIsStale = active.kind !== 'idle' && ( - currentIdentity === undefined || - !sameRootChangeRequestIdentity(active.identity, currentIdentity) - ); - const previewIsStale = pendingPreview !== undefined && ( - currentIdentity === undefined || - !sameRootChangeRequestIdentity(pendingPreview.identity, currentIdentity) - ); - if (!activeIsStale && !previewIsStale) return; - - rowChangeAssessmentRequest.current?.abort(); - rowChangeAssessmentRequest.current = undefined; - rowChangePreviewRequest.current?.abort(); - rowChangePreviewRequest.current = undefined; - rowChangeRequestEpoch.current += 1; - setPendingRowChangePreview(undefined); - - if (activeIsStale) { - const sameExplorerOutput = active.identity.project === projectId && - active.identity.authResourcePath === authResourcePath && - active.identity.explorerId === state.explorerId && - active.identity.outputId === table?.outputId; - setRootChangeState(sameExplorerOutput - ? { - kind: 'failed', - reason: 'stale', - selection: active.selection, - resolution: active.resolution, - identity: active.identity, - message: 'The table changed while Loom checked this row choice. Refresh the choices or keep the current rows.', - } - : { kind: 'idle' }); - } - if (previewIsStale) { - setMessage('The table changed while Loom prepared the row preview. Review the change again.'); - } - }, [authResourcePath, projectId, state.explorerId, state.catalog.snapshotToken, - state.draftVersion, state.draftDigest, table?.outputId, pendingRowChangePreview]); useEffect(() => { addColumnsRequest.current?.abort(); addColumnsRequest.current = undefined; @@ -2091,278 +2030,6 @@ const BuilderWorkspaceContent = ({ } }, [activePopulationSelection, applyCommands, authResourcePath, loomClient, populationSelectionContextKey, projectId]); - const getCurrentRootChangeIdentity = useCallback((): RootChangeRequestIdentity | undefined => { - const current = latestState.current; - const currentTable = selectedTable(current); - if (!currentTable) return undefined; - return { - epoch: rowChangeRequestEpoch.current, - project: projectId, - ...(authResourcePath ? { authResourcePath } : {}), - explorerId: current.explorerId, - outputId: currentTable.outputId, - snapshotToken: current.catalog.snapshotToken, - draftVersion: serverDraft.current.version, - draftDigest: serverDraft.current.digest, - }; - }, [authResourcePath, projectId]); - - const abortRootChangeRequests = useCallback(() => { - rowChangeAssessmentRequest.current?.abort(); - rowChangeAssessmentRequest.current = undefined; - rowChangePreviewRequest.current?.abort(); - rowChangePreviewRequest.current = undefined; - }, []); - - const cancelRootChange = useCallback(() => { - abortRootChangeRequests(); - rowChangeRequestEpoch.current += 1; - setPendingRowChangePreview(undefined); - setRootChangeState({ kind: 'idle' }); - }, [abortRootChangeRequests]); - - const changeTableRoot = useCallback( - async (selection: RootChangeSelection, resolution: RootChangeResolution = {}) => { - abortRootChangeRequests(); - rowChangeRequestEpoch.current += 1; - const epoch = rowChangeRequestEpoch.current; - const identity = getCurrentRootChangeIdentity(); - if (!identity) { - setRootChangeState({ kind: 'idle' }); - return; - } - setPendingRowChangePreview(undefined); - setMessage(undefined); - setRootChangeState({ kind: 'assessing', selection, resolution, identity }); - const isCurrentAttempt = () => { - const currentIdentity = getCurrentRootChangeIdentity(); - return workspaceAttached.current && rowChangeRequestEpoch.current === epoch && currentIdentity !== undefined && - sameRootChangeRequestIdentity(identity, currentIdentity); - }; - const request = assessRowChange({ - project: identity.project, - explorerId: identity.explorerId, - authResourcePath: identity.authResourcePath, - snapshotToken: identity.snapshotToken, - draftVersion: identity.draftVersion, - draftDigest: identity.draftDigest, - outputId: identity.outputId, - rootNodeId: selection.nodeId, - ...resolution, - requestId: `row-change-${window.crypto.randomUUID()}`, - }); - rowChangeAssessmentRequest.current = request; - try { - const assessment = await request.unwrap(); - if (!isCurrentAttempt()) return; - if (assessment.snapshotToken !== identity.snapshotToken || - assessment.draftVersion !== identity.draftVersion || - assessment.draftDigest !== identity.draftDigest) { - setRootChangeState({ - kind: 'failed', reason: 'stale', selection, resolution, identity, - message: 'The table changed while Loom checked this row choice. Refresh the choices or keep the current rows.', - }); - return; - } - if (assessment.status === 'NO_CHANGE') { - setRootChangeState({ kind: 'idle' }); - setMessage(undefined); - return; - } - if (assessment.status === 'BLOCKED') { - setRootChangeState({ kind: 'blocked', selection, resolution, identity, assessment }); - setMessage(undefined); - return; - } - const candidateReceiptId = assessment.candidateReceiptId; - if (!candidateReceiptId || assessment.proposal.outputId !== identity.outputId || - assessment.proposal.rootNodeId !== selection.nodeId || - (resolution.rootOccurrenceId !== undefined && - assessment.proposal.rootOccurrenceId !== resolution.rootOccurrenceId)) { - setRootChangeState({ - kind: 'failed', reason: 'unavailable', selection, resolution, identity, - message: 'Loom could not match this row choice to a preview receipt. The table has not changed.', - }); - return; - } - const context: RowChangePreviewContext = { assessment, identity }; - setRootChangeState({ kind: 'previewing', selection, resolution, identity }); - setPendingRowChangePreview({ ...context, status: 'loading' }); - const controller = new AbortController(); - rowChangePreviewRequest.current = controller; - try { - const candidatePreview = await loomClient.preview({ - project: identity.project, - explorerId: identity.explorerId, - authResourcePath: identity.authResourcePath, - receiptId: candidateReceiptId, - outputId: identity.outputId, - limit: previewLimit, - requestId: `row-change-preview-${window.crypto.randomUUID()}`, - }, controller.signal); - if (controller.signal.aborted || !isCurrentAttempt()) return; - if (candidatePreview.receiptId !== candidateReceiptId || - candidatePreview.outputId !== identity.outputId || - candidatePreview.rows === null) { - setPendingRowChangePreview(undefined); - setRootChangeState({ - kind: 'failed', reason: 'unavailable', selection, resolution, identity, - message: 'Loom returned a preview that does not match the selected row change. The table has not changed.', - }); - return; - } - setPendingRowChangePreview({ ...context, status: 'ready', preview: candidatePreview }); - setRootChangeState({ kind: 'idle' }); - } catch (error) { - if (controller.signal.aborted || !isCurrentAttempt()) return; - setPendingRowChangePreview(undefined); - setRootChangeState({ - kind: 'failed', reason: 'request', selection, resolution, identity, - message: error instanceof Error ? error.message : 'Loom could not preview the proposed rows.', - }); - } finally { - if (rowChangePreviewRequest.current === controller) rowChangePreviewRequest.current = undefined; - } - } catch (error) { - if (!isCurrentAttempt()) return; - const apiError = typeof error === 'object' && error !== null && 'code' in error && - typeof error.code === 'string' - ? { - code: error.code, - ...( 'message' in error && typeof error.message === 'string' - ? { message: error.message } - : {}), - } - : undefined; - if (apiError && isDraftRestoreError(apiError.code)) { - const refreshed = await refetchBuilder({ reload: true }); - if (!isCurrentAttempt()) return; - if (refreshed.data) syncBuilderData(refreshed.data, 'hydrate'); - setRootChangeState({ - kind: 'failed', reason: 'stale', selection, resolution, identity, - message: 'The table changed while Loom checked this row choice. Refresh the choices or keep the current rows.', - }); - return; - } - if (apiError?.code === 'CLIENT_CANCELLED') return; - setRootChangeState({ - kind: 'failed', reason: 'request', selection, resolution, identity, - message: apiError?.message ?? (error instanceof Error ? error.message : 'Loom could not assess the row change.'), - }); - } finally { - if (rowChangeAssessmentRequest.current === request) rowChangeAssessmentRequest.current = undefined; - } - }, - [abortRootChangeRequests, assessRowChange, getCurrentRootChangeIdentity, loomClient, - previewLimit, projectId, refetchBuilder, syncBuilderData], - ); - - const selectRootChoice = useCallback((selection: RootChangeSelection) => { - const active = rootChangeStateRef.current; - const sameTarget = active.kind !== 'idle' && active.selection.nodeId === selection.nodeId; - const currentIdentity = getCurrentRootChangeIdentity(); - const validPreviousResolution = active.kind === 'blocked' && sameTarget && - currentIdentity !== undefined && - sameRootChangeRequestIdentity(active.identity, currentIdentity) - ? active.resolution - : {}; - if (isCompilerNavigationSelection(selection)) { - const isCurrentBlocked = active.kind === 'blocked' && currentIdentity !== undefined && - sameRootChangeRequestIdentity(active.identity, currentIdentity); - const alternative = selection.kind === 'compiler-route' - ? selection.routeToken - : selection.occurrenceId; - const reference = isCurrentBlocked - ? active.assessment.unresolved.find((candidate) => - candidate.id === selection.referenceId && - candidate.code === 'AMBIGUOUS_ROW_ROOT_OCCURRENCE' && - candidate.alternatives?.includes(alternative), - ) - : undefined; - if (!reference || !isCurrentBlocked || active.kind !== 'blocked') { - if (currentIdentity) setRootChangeState({ - kind: 'failed', reason: 'unavailable', selection: active.kind === 'idle' ? selection : active.selection, - resolution: active.kind === 'idle' ? {} : active.resolution, - identity: active.kind === 'idle' ? currentIdentity : active.identity, - message: 'That relationship choice is no longer available. Refresh the choices or keep the current rows.', - }); - return; - } - void changeTableRoot(selection, { - ...active.resolution, - rootOccurrenceId: alternative, - }); - return; - } - const resolution: RootChangeResolution = { - ...(validPreviousResolution.routeRebase?.length - ? { routeRebase: validPreviousResolution.routeRebase } - : {}), - ...(selection.kind === 'authored-occurrence' - ? { rootOccurrenceId: selection.occurrenceId } - : {}), - }; - void changeTableRoot(selection, resolution); - }, [changeTableRoot, getCurrentRootChangeIdentity]); - - const resolveRootChange = useCallback((referenceId: string, alternative: string) => { - const active = rootChangeStateRef.current; - const currentIdentity = getCurrentRootChangeIdentity(); - const isCurrentBlocked = active.kind === 'blocked' && currentIdentity !== undefined && - sameRootChangeRequestIdentity(active.identity, currentIdentity); - const reference = isCurrentBlocked && active.kind === 'blocked' - ? active.assessment.unresolved.find((candidate) => - candidate.id === referenceId && - candidate.code === 'AMBIGUOUS_ROUTE_REBASE_EDGE' && - candidate.alternatives?.includes(alternative), - ) - : undefined; - if (!reference || !isCurrentBlocked || active.kind !== 'blocked') { - if (active.kind !== 'idle') setRootChangeState({ - kind: 'failed', reason: 'unavailable', selection: active.selection, - resolution: active.resolution, identity: active.identity, - message: 'That relationship choice is no longer available. Refresh the choices or keep the current rows.', - }); - return; - } - const resolution: RootChangeResolution = { - ...active.resolution, - routeRebase: [ - ...(active.resolution.routeRebase ?? []).filter( - (choice) => choice.occurrenceId !== referenceId, - ), - { occurrenceId: referenceId, edgeId: alternative }, - ], - }; - void changeTableRoot(active.selection, resolution); - }, [changeTableRoot, getCurrentRootChangeIdentity]); - - const refreshRootChange = useCallback(() => { - const active = rootChangeStateRef.current; - if (active.kind === 'idle') return; - if (active.kind === 'failed' && active.reason !== 'request') { - void changeTableRoot({ kind: 'catalog-node', nodeId: active.selection.nodeId }, {}); - return; - } - void changeTableRoot(active.selection, active.resolution); - }, [changeTableRoot]); - - const applyRowChangePreview = useCallback(async () => { - const pending = pendingRowChangePreview; - if (pending?.status !== 'ready') return; - const currentIdentity = getCurrentRootChangeIdentity(); - if (!currentIdentity || !sameRootChangeRequestIdentity(pending.identity, currentIdentity)) { - setPendingRowChangePreview(undefined); - setMessage('The table changed. Review the proposed rows again.'); - return; - } - const applied = await applyCommands([{ - type: 'APPLY_TABLE_ROOT_REBASE', - rowChange: pending.assessment.proposal, - }]); - if (applied) setPendingRowChangePreview(undefined); - }, [applyCommands, getCurrentRootChangeIdentity, pendingRowChangePreview]); - const ensureSuggestions = useCallback(() => { const current = latestState.current; const currentOccurrence = derivedOccurrences( @@ -4279,10 +3946,6 @@ const BuilderWorkspaceContent = ({ ); - const rootChangePreviewReady = pendingRowChangePreview?.status === 'ready' && - currentRootChangeIdentity !== undefined && - sameRootChangeRequestIdentity(pendingRowChangePreview.identity, currentRootChangeIdentity); - return (
{toolbarHost ? createPortal(toolbar, toolbarHost) : toolbar} @@ -4410,11 +4073,11 @@ const BuilderWorkspaceContent = ({ ) : null} - {rootChangePreviewReady && pendingRowChangePreview?.status === 'ready' ? ( + {readyRowChangePreview ? ( 0} onApply={() => void applyRowChangePreview()} onCancel={cancelRootChange} @@ -4562,7 +4225,7 @@ const BuilderWorkspaceContent = ({ onChangeRootSelection={selectRootChoice} onResolveRootSelection={resolveRootChange} rootChangeState={rootChangeState} - rootChangePreviewOpen={rootChangePreviewReady} + rootChangePreviewOpen={readyRowChangePreview !== undefined} onCancelRootChange={cancelRootChange} onEditConstructionStep={editConstructionStep} onRemoveConstructionStep={removeConstructionStep} diff --git a/ui/packages/loom-ui/src/features/ExplorerBuilder/authoring/useRowChangeWorkflow.ts b/ui/packages/loom-ui/src/features/ExplorerBuilder/authoring/useRowChangeWorkflow.ts new file mode 100644 index 000000000..a7a65ed56 --- /dev/null +++ b/ui/packages/loom-ui/src/features/ExplorerBuilder/authoring/useRowChangeWorkflow.ts @@ -0,0 +1,442 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { MutableRefObject } from 'react'; +import type { AssessExplorerRowChangeArgs, LoomClient } from '../../../api'; +import type { + ExplorerBuilderCommand, + ExplorerBuilderPreviewResult, + ExplorerBuilderState, + RowChangeAssessment, +} from '../../../types'; +import { isDraftRestoreError } from './draftRestore'; +import { selectedTable, type BuilderAuthoringState } from './model'; +import type { PreviewLimit } from './previewRecovery'; +import { + sameRootChangeRequestIdentity, + type RootChangePickerState, + type RootChangeRequestIdentity, + type RootChangeResolution, + type RootChangeSelection, +} from './rootChangeSelection'; +import { isCompilerNavigationSelection } from './workspaceGuards'; + +export type RowChangePreviewContext = { + readonly assessment: Extract; + readonly identity: RootChangeRequestIdentity; +}; + +export type PendingRowChangePreview = + | (RowChangePreviewContext & { readonly status: 'loading' }) + | (RowChangePreviewContext & { readonly status: 'ready'; readonly preview: ExplorerBuilderPreviewResult }); + +type RowChangeAssessmentRequest = { + readonly unwrap: () => Promise; + readonly abort: () => void; +}; + +export type RowChangeWorkflowOptions = { + readonly applyCommands: (commands: ReadonlyArray) => Promise; + readonly assessRowChange: (args: AssessExplorerRowChangeArgs) => RowChangeAssessmentRequest; + readonly authResourcePath: string | undefined; + readonly draftDigest: string; + readonly draftVersion: number; + readonly explorerId: string; + readonly latestState: MutableRefObject; + readonly loomClient: LoomClient; + readonly previewLimit: PreviewLimit; + readonly projectId: string; + readonly refetchBuilder: (options?: { readonly reload?: boolean }) => Promise<{ readonly data?: ExplorerBuilderState }>; + readonly selectedOutputId: string | undefined; + readonly serverDraft: MutableRefObject<{ readonly version: number; readonly digest: string }>; + readonly setMessage: (message: string | undefined) => void; + readonly snapshotToken: string; + readonly syncBuilderData: (value: ExplorerBuilderState, mode: 'hydrate') => void; + readonly workspaceAttached: MutableRefObject; +}; + +export const useRowChangeWorkflow = (options: RowChangeWorkflowOptions) => { + const { + applyCommands, + assessRowChange, + authResourcePath, + draftDigest, + draftVersion, + explorerId, + latestState, + loomClient, + previewLimit, + projectId, + refetchBuilder, + selectedOutputId, + serverDraft, + setMessage, + snapshotToken, + syncBuilderData, + workspaceAttached, + } = options; + + const [rootChangeState, setRootChangeState] = + useState({ kind: 'idle' }); + const rootChangeStateRef = useRef(rootChangeState); + rootChangeStateRef.current = rootChangeState; + const [pendingRowChangePreview, setPendingRowChangePreview] = + useState(); + const rowChangeAssessmentRequest = useRef<{ readonly abort: () => void } | undefined>(undefined); + const rowChangeRequestEpoch = useRef(0); + const rowChangePreviewRequest = useRef(undefined); + + useEffect(() => { + const currentIdentity: RootChangeRequestIdentity | undefined = selectedOutputId !== undefined ? { + epoch: rowChangeRequestEpoch.current, + project: projectId, + ...(authResourcePath ? { authResourcePath } : {}), + explorerId, + outputId: selectedOutputId, + snapshotToken, + draftVersion: serverDraft.current.version, + draftDigest: serverDraft.current.digest, + } : undefined; + const active = rootChangeStateRef.current; + const pendingPreview = pendingRowChangePreview; + const activeIsStale = active.kind !== 'idle' && ( + currentIdentity === undefined || + !sameRootChangeRequestIdentity(active.identity, currentIdentity) + ); + const previewIsStale = pendingPreview !== undefined && ( + currentIdentity === undefined || + !sameRootChangeRequestIdentity(pendingPreview.identity, currentIdentity) + ); + if (!activeIsStale && !previewIsStale) return; + + rowChangeAssessmentRequest.current?.abort(); + rowChangeAssessmentRequest.current = undefined; + rowChangePreviewRequest.current?.abort(); + rowChangePreviewRequest.current = undefined; + rowChangeRequestEpoch.current += 1; + setPendingRowChangePreview(undefined); + + if (activeIsStale) { + const sameExplorerOutput = active.identity.project === projectId && + active.identity.authResourcePath === authResourcePath && + active.identity.explorerId === explorerId && + active.identity.outputId === selectedOutputId; + setRootChangeState(sameExplorerOutput + ? { + kind: 'failed', + reason: 'stale', + selection: active.selection, + resolution: active.resolution, + identity: active.identity, + message: 'The table changed while Loom checked this row choice. Refresh the choices or keep the current rows.', + } + : { kind: 'idle' }); + } + if (previewIsStale) { + setMessage('The table changed while Loom prepared the row preview. Review the change again.'); + } + }, [authResourcePath, projectId, explorerId, snapshotToken, + draftVersion, draftDigest, selectedOutputId, pendingRowChangePreview]); + + const getCurrentRootChangeIdentity = useCallback((): RootChangeRequestIdentity | undefined => { + const current = latestState.current; + const currentTable = selectedTable(current); + if (!currentTable) return undefined; + return { + epoch: rowChangeRequestEpoch.current, + project: projectId, + ...(authResourcePath ? { authResourcePath } : {}), + explorerId: current.explorerId, + outputId: currentTable.outputId, + snapshotToken: current.catalog.snapshotToken, + draftVersion: serverDraft.current.version, + draftDigest: serverDraft.current.digest, + }; + }, [authResourcePath, projectId]); + + const abortRootChangeRequests = useCallback(() => { + rowChangeAssessmentRequest.current?.abort(); + rowChangeAssessmentRequest.current = undefined; + rowChangePreviewRequest.current?.abort(); + rowChangePreviewRequest.current = undefined; + }, []); + + const cancelRootChange = useCallback(() => { + abortRootChangeRequests(); + rowChangeRequestEpoch.current += 1; + setPendingRowChangePreview(undefined); + setRootChangeState({ kind: 'idle' }); + }, [abortRootChangeRequests]); + + const changeTableRoot = useCallback( + async (selection: RootChangeSelection, resolution: RootChangeResolution = {}) => { + abortRootChangeRequests(); + rowChangeRequestEpoch.current += 1; + const epoch = rowChangeRequestEpoch.current; + const identity = getCurrentRootChangeIdentity(); + if (!identity) { + setRootChangeState({ kind: 'idle' }); + return; + } + setPendingRowChangePreview(undefined); + setMessage(undefined); + setRootChangeState({ kind: 'assessing', selection, resolution, identity }); + const isCurrentAttempt = () => { + const currentIdentity = getCurrentRootChangeIdentity(); + return workspaceAttached.current && rowChangeRequestEpoch.current === epoch && currentIdentity !== undefined && + sameRootChangeRequestIdentity(identity, currentIdentity); + }; + const request = assessRowChange({ + project: identity.project, + explorerId: identity.explorerId, + authResourcePath: identity.authResourcePath, + snapshotToken: identity.snapshotToken, + draftVersion: identity.draftVersion, + draftDigest: identity.draftDigest, + outputId: identity.outputId, + rootNodeId: selection.nodeId, + ...resolution, + requestId: `row-change-${window.crypto.randomUUID()}`, + }); + rowChangeAssessmentRequest.current = request; + try { + const assessment = await request.unwrap(); + if (!isCurrentAttempt()) return; + if (assessment.snapshotToken !== identity.snapshotToken || + assessment.draftVersion !== identity.draftVersion || + assessment.draftDigest !== identity.draftDigest) { + setRootChangeState({ + kind: 'failed', reason: 'stale', selection, resolution, identity, + message: 'The table changed while Loom checked this row choice. Refresh the choices or keep the current rows.', + }); + return; + } + if (assessment.status === 'NO_CHANGE') { + setRootChangeState({ kind: 'idle' }); + setMessage(undefined); + return; + } + if (assessment.status === 'BLOCKED') { + setRootChangeState({ kind: 'blocked', selection, resolution, identity, assessment }); + setMessage(undefined); + return; + } + const candidateReceiptId = assessment.candidateReceiptId; + if (!candidateReceiptId || assessment.proposal.outputId !== identity.outputId || + assessment.proposal.rootNodeId !== selection.nodeId || + (resolution.rootOccurrenceId !== undefined && + assessment.proposal.rootOccurrenceId !== resolution.rootOccurrenceId)) { + setRootChangeState({ + kind: 'failed', reason: 'unavailable', selection, resolution, identity, + message: 'Loom could not match this row choice to a preview receipt. The table has not changed.', + }); + return; + } + const context: RowChangePreviewContext = { assessment, identity }; + setRootChangeState({ kind: 'previewing', selection, resolution, identity }); + setPendingRowChangePreview({ ...context, status: 'loading' }); + const controller = new AbortController(); + rowChangePreviewRequest.current = controller; + try { + const candidatePreview = await loomClient.preview({ + project: identity.project, + explorerId: identity.explorerId, + authResourcePath: identity.authResourcePath, + receiptId: candidateReceiptId, + outputId: identity.outputId, + limit: previewLimit, + requestId: `row-change-preview-${window.crypto.randomUUID()}`, + }, controller.signal); + if (controller.signal.aborted || !isCurrentAttempt()) return; + if (candidatePreview.receiptId !== candidateReceiptId || + candidatePreview.outputId !== identity.outputId || + candidatePreview.rows === null) { + setPendingRowChangePreview(undefined); + setRootChangeState({ + kind: 'failed', reason: 'unavailable', selection, resolution, identity, + message: 'Loom returned a preview that does not match the selected row change. The table has not changed.', + }); + return; + } + setPendingRowChangePreview({ ...context, status: 'ready', preview: candidatePreview }); + setRootChangeState({ kind: 'idle' }); + } catch (error) { + if (controller.signal.aborted || !isCurrentAttempt()) return; + setPendingRowChangePreview(undefined); + setRootChangeState({ + kind: 'failed', reason: 'request', selection, resolution, identity, + message: error instanceof Error ? error.message : 'Loom could not preview the proposed rows.', + }); + } finally { + if (rowChangePreviewRequest.current === controller) rowChangePreviewRequest.current = undefined; + } + } catch (error) { + if (!isCurrentAttempt()) return; + const apiError = typeof error === 'object' && error !== null && 'code' in error && + typeof error.code === 'string' + ? { + code: error.code, + ...( 'message' in error && typeof error.message === 'string' + ? { message: error.message } + : {}), + } + : undefined; + if (apiError && isDraftRestoreError(apiError.code)) { + const refreshed = await refetchBuilder({ reload: true }); + if (!isCurrentAttempt()) return; + if (refreshed.data) syncBuilderData(refreshed.data, 'hydrate'); + setRootChangeState({ + kind: 'failed', reason: 'stale', selection, resolution, identity, + message: 'The table changed while Loom checked this row choice. Refresh the choices or keep the current rows.', + }); + return; + } + if (apiError?.code === 'CLIENT_CANCELLED') return; + setRootChangeState({ + kind: 'failed', reason: 'request', selection, resolution, identity, + message: apiError?.message ?? (error instanceof Error ? error.message : 'Loom could not assess the row change.'), + }); + } finally { + if (rowChangeAssessmentRequest.current === request) rowChangeAssessmentRequest.current = undefined; + } + }, + [abortRootChangeRequests, assessRowChange, getCurrentRootChangeIdentity, loomClient, + previewLimit, projectId, refetchBuilder, syncBuilderData], + ); + + const selectRootChoice = useCallback((selection: RootChangeSelection) => { + const active = rootChangeStateRef.current; + const sameTarget = active.kind !== 'idle' && active.selection.nodeId === selection.nodeId; + const currentIdentity = getCurrentRootChangeIdentity(); + const validPreviousResolution = active.kind === 'blocked' && sameTarget && + currentIdentity !== undefined && + sameRootChangeRequestIdentity(active.identity, currentIdentity) + ? active.resolution + : {}; + if (isCompilerNavigationSelection(selection)) { + const isCurrentBlocked = active.kind === 'blocked' && currentIdentity !== undefined && + sameRootChangeRequestIdentity(active.identity, currentIdentity); + const alternative = selection.kind === 'compiler-route' + ? selection.routeToken + : selection.occurrenceId; + const reference = isCurrentBlocked + ? active.assessment.unresolved.find((candidate) => + candidate.id === selection.referenceId && + candidate.code === 'AMBIGUOUS_ROW_ROOT_OCCURRENCE' && + candidate.alternatives?.includes(alternative), + ) + : undefined; + if (!reference || !isCurrentBlocked || active.kind !== 'blocked') { + if (currentIdentity) setRootChangeState({ + kind: 'failed', reason: 'unavailable', selection: active.kind === 'idle' ? selection : active.selection, + resolution: active.kind === 'idle' ? {} : active.resolution, + identity: active.kind === 'idle' ? currentIdentity : active.identity, + message: 'That relationship choice is no longer available. Refresh the choices or keep the current rows.', + }); + return; + } + void changeTableRoot(selection, { + ...active.resolution, + rootOccurrenceId: alternative, + }); + return; + } + const resolution: RootChangeResolution = { + ...(validPreviousResolution.routeRebase?.length + ? { routeRebase: validPreviousResolution.routeRebase } + : {}), + ...(selection.kind === 'authored-occurrence' + ? { rootOccurrenceId: selection.occurrenceId } + : {}), + }; + void changeTableRoot(selection, resolution); + }, [changeTableRoot, getCurrentRootChangeIdentity]); + + const resolveRootChange = useCallback((referenceId: string, alternative: string) => { + const active = rootChangeStateRef.current; + const currentIdentity = getCurrentRootChangeIdentity(); + const isCurrentBlocked = active.kind === 'blocked' && currentIdentity !== undefined && + sameRootChangeRequestIdentity(active.identity, currentIdentity); + const reference = isCurrentBlocked && active.kind === 'blocked' + ? active.assessment.unresolved.find((candidate) => + candidate.id === referenceId && + candidate.code === 'AMBIGUOUS_ROUTE_REBASE_EDGE' && + candidate.alternatives?.includes(alternative), + ) + : undefined; + if (!reference || !isCurrentBlocked || active.kind !== 'blocked') { + if (active.kind !== 'idle') setRootChangeState({ + kind: 'failed', reason: 'unavailable', selection: active.selection, + resolution: active.resolution, identity: active.identity, + message: 'That relationship choice is no longer available. Refresh the choices or keep the current rows.', + }); + return; + } + const resolution: RootChangeResolution = { + ...active.resolution, + routeRebase: [ + ...(active.resolution.routeRebase ?? []).filter( + (choice) => choice.occurrenceId !== referenceId, + ), + { occurrenceId: referenceId, edgeId: alternative }, + ], + }; + void changeTableRoot(active.selection, resolution); + }, [changeTableRoot, getCurrentRootChangeIdentity]); + + const refreshRootChange = useCallback(() => { + const active = rootChangeStateRef.current; + if (active.kind === 'idle') return; + if (active.kind === 'failed' && active.reason !== 'request') { + void changeTableRoot({ kind: 'catalog-node', nodeId: active.selection.nodeId }, {}); + return; + } + void changeTableRoot(active.selection, active.resolution); + }, [changeTableRoot]); + + const applyRowChangePreview = useCallback(async () => { + const pending = pendingRowChangePreview; + if (pending?.status !== 'ready') return; + const currentIdentity = getCurrentRootChangeIdentity(); + if (!currentIdentity || !sameRootChangeRequestIdentity(pending.identity, currentIdentity)) { + setPendingRowChangePreview(undefined); + setMessage('The table changed. Review the proposed rows again.'); + return; + } + const applied = await applyCommands([{ + type: 'APPLY_TABLE_ROOT_REBASE', + rowChange: pending.assessment.proposal, + }]); + if (applied) setPendingRowChangePreview(undefined); + }, [applyCommands, getCurrentRootChangeIdentity, pendingRowChangePreview]); + + const deriveIdentity = (outputId: string): RootChangeRequestIdentity => ({ + epoch: rowChangeRequestEpoch.current, + project: projectId, + ...(authResourcePath ? { authResourcePath } : {}), + explorerId, + outputId, + snapshotToken, + draftVersion: serverDraft.current.version, + draftDigest: serverDraft.current.digest, + }); + + const currentRootChangeIdentity = selectedOutputId !== undefined + ? deriveIdentity(selectedOutputId) + : undefined; + const readyRowChangePreview = pendingRowChangePreview?.status === 'ready' && + currentRootChangeIdentity !== undefined && + sameRootChangeRequestIdentity(pendingRowChangePreview.identity, currentRootChangeIdentity) + ? pendingRowChangePreview + : undefined; + + return { + applyRowChangePreview, + cancelRootChange, + deriveIdentity, + pendingRowChangePreview, + readyRowChangePreview, + refreshRootChange, + resolveRootChange, + rootChangeState, + selectRootChoice, + }; +};