|
| 1 | +import { useCallback, useEffect, useMemo, useRef } from 'react' |
| 2 | +import { createLogger } from '@sim/logger' |
| 3 | +import { useShallow } from 'zustand/react/shallow' |
| 4 | +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' |
| 5 | +import { useCodeUndoRedoStore } from '@/stores/undo-redo' |
| 6 | +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' |
| 7 | +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' |
| 8 | + |
| 9 | +const logger = createLogger('CodeUndoRedo') |
| 10 | + |
| 11 | +interface UseCodeUndoRedoOptions { |
| 12 | + blockId: string |
| 13 | + subBlockId: string |
| 14 | + value: string |
| 15 | + enabled?: boolean |
| 16 | + isReadOnly?: boolean |
| 17 | + isStreaming?: boolean |
| 18 | + debounceMs?: number |
| 19 | +} |
| 20 | + |
| 21 | +export function useCodeUndoRedo({ |
| 22 | + blockId, |
| 23 | + subBlockId, |
| 24 | + value, |
| 25 | + enabled = true, |
| 26 | + isReadOnly = false, |
| 27 | + isStreaming = false, |
| 28 | + debounceMs = 500, |
| 29 | +}: UseCodeUndoRedoOptions) { |
| 30 | + const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() |
| 31 | + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) |
| 32 | + const { isShowingDiff, hasActiveDiff } = useWorkflowDiffStore( |
| 33 | + useShallow((state) => ({ |
| 34 | + isShowingDiff: state.isShowingDiff, |
| 35 | + hasActiveDiff: state.hasActiveDiff, |
| 36 | + })) |
| 37 | + ) |
| 38 | + |
| 39 | + const isBaselineView = hasActiveDiff && !isShowingDiff |
| 40 | + const isEnabled = useMemo( |
| 41 | + () => Boolean(enabled && activeWorkflowId && !isReadOnly && !isStreaming && !isBaselineView), |
| 42 | + [enabled, activeWorkflowId, isReadOnly, isStreaming, isBaselineView] |
| 43 | + ) |
| 44 | + const isReplaceEnabled = useMemo( |
| 45 | + () => Boolean(enabled && activeWorkflowId && !isReadOnly && !isBaselineView), |
| 46 | + [enabled, activeWorkflowId, isReadOnly, isBaselineView] |
| 47 | + ) |
| 48 | + |
| 49 | + const lastCommittedValueRef = useRef<string>(value ?? '') |
| 50 | + const pendingBeforeRef = useRef<string | null>(null) |
| 51 | + const pendingAfterRef = useRef<string | null>(null) |
| 52 | + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
| 53 | + const isApplyingRef = useRef(false) |
| 54 | + |
| 55 | + const clearTimer = useCallback(() => { |
| 56 | + if (timeoutRef.current) { |
| 57 | + clearTimeout(timeoutRef.current) |
| 58 | + timeoutRef.current = null |
| 59 | + } |
| 60 | + }, []) |
| 61 | + |
| 62 | + const resetPending = useCallback(() => { |
| 63 | + pendingBeforeRef.current = null |
| 64 | + pendingAfterRef.current = null |
| 65 | + }, []) |
| 66 | + |
| 67 | + const commitPending = useCallback(() => { |
| 68 | + if (!isEnabled || !activeWorkflowId) { |
| 69 | + clearTimer() |
| 70 | + resetPending() |
| 71 | + return |
| 72 | + } |
| 73 | + |
| 74 | + const before = pendingBeforeRef.current |
| 75 | + const after = pendingAfterRef.current |
| 76 | + if (before === null || after === null) return |
| 77 | + |
| 78 | + if (before === after) { |
| 79 | + lastCommittedValueRef.current = after |
| 80 | + clearTimer() |
| 81 | + resetPending() |
| 82 | + return |
| 83 | + } |
| 84 | + |
| 85 | + useCodeUndoRedoStore.getState().push({ |
| 86 | + id: crypto.randomUUID(), |
| 87 | + createdAt: Date.now(), |
| 88 | + workflowId: activeWorkflowId, |
| 89 | + blockId, |
| 90 | + subBlockId, |
| 91 | + before, |
| 92 | + after, |
| 93 | + }) |
| 94 | + |
| 95 | + lastCommittedValueRef.current = after |
| 96 | + clearTimer() |
| 97 | + resetPending() |
| 98 | + }, [activeWorkflowId, blockId, clearTimer, isEnabled, resetPending, subBlockId]) |
| 99 | + |
| 100 | + const recordChange = useCallback( |
| 101 | + (nextValue: string) => { |
| 102 | + if (!isEnabled || isApplyingRef.current) return |
| 103 | + |
| 104 | + if (pendingBeforeRef.current === null) { |
| 105 | + pendingBeforeRef.current = lastCommittedValueRef.current ?? '' |
| 106 | + } |
| 107 | + |
| 108 | + pendingAfterRef.current = nextValue |
| 109 | + clearTimer() |
| 110 | + timeoutRef.current = setTimeout(commitPending, debounceMs) |
| 111 | + }, |
| 112 | + [clearTimer, commitPending, debounceMs, isEnabled] |
| 113 | + ) |
| 114 | + |
| 115 | + const recordReplace = useCallback( |
| 116 | + (nextValue: string) => { |
| 117 | + if (!isReplaceEnabled || isApplyingRef.current || !activeWorkflowId) return |
| 118 | + |
| 119 | + if (pendingBeforeRef.current !== null) { |
| 120 | + commitPending() |
| 121 | + } |
| 122 | + |
| 123 | + const before = lastCommittedValueRef.current ?? '' |
| 124 | + if (before === nextValue) { |
| 125 | + lastCommittedValueRef.current = nextValue |
| 126 | + resetPending() |
| 127 | + return |
| 128 | + } |
| 129 | + |
| 130 | + useCodeUndoRedoStore.getState().push({ |
| 131 | + id: crypto.randomUUID(), |
| 132 | + createdAt: Date.now(), |
| 133 | + workflowId: activeWorkflowId, |
| 134 | + blockId, |
| 135 | + subBlockId, |
| 136 | + before, |
| 137 | + after: nextValue, |
| 138 | + }) |
| 139 | + |
| 140 | + lastCommittedValueRef.current = nextValue |
| 141 | + clearTimer() |
| 142 | + resetPending() |
| 143 | + }, |
| 144 | + [ |
| 145 | + activeWorkflowId, |
| 146 | + blockId, |
| 147 | + clearTimer, |
| 148 | + commitPending, |
| 149 | + isReplaceEnabled, |
| 150 | + resetPending, |
| 151 | + subBlockId, |
| 152 | + ] |
| 153 | + ) |
| 154 | + |
| 155 | + const flushPending = useCallback(() => { |
| 156 | + if (pendingBeforeRef.current === null) return |
| 157 | + clearTimer() |
| 158 | + commitPending() |
| 159 | + }, [clearTimer, commitPending]) |
| 160 | + |
| 161 | + const startSession = useCallback( |
| 162 | + (currentValue: string) => { |
| 163 | + clearTimer() |
| 164 | + resetPending() |
| 165 | + lastCommittedValueRef.current = currentValue ?? '' |
| 166 | + }, |
| 167 | + [clearTimer, resetPending] |
| 168 | + ) |
| 169 | + |
| 170 | + const applyValue = useCallback( |
| 171 | + (nextValue: string) => { |
| 172 | + if (!isEnabled) return |
| 173 | + isApplyingRef.current = true |
| 174 | + try { |
| 175 | + collaborativeSetSubblockValue(blockId, subBlockId, nextValue) |
| 176 | + } finally { |
| 177 | + isApplyingRef.current = false |
| 178 | + } |
| 179 | + lastCommittedValueRef.current = nextValue |
| 180 | + clearTimer() |
| 181 | + resetPending() |
| 182 | + }, |
| 183 | + [blockId, clearTimer, collaborativeSetSubblockValue, isEnabled, resetPending, subBlockId] |
| 184 | + ) |
| 185 | + |
| 186 | + const undo = useCallback(() => { |
| 187 | + if (!activeWorkflowId || !isEnabled) return |
| 188 | + if (pendingBeforeRef.current !== null) { |
| 189 | + flushPending() |
| 190 | + } |
| 191 | + const entry = useCodeUndoRedoStore.getState().undo(activeWorkflowId, blockId, subBlockId) |
| 192 | + if (!entry) return |
| 193 | + logger.debug('Undo code edit', { blockId, subBlockId }) |
| 194 | + applyValue(entry.before) |
| 195 | + }, [activeWorkflowId, applyValue, blockId, flushPending, isEnabled, subBlockId]) |
| 196 | + |
| 197 | + const redo = useCallback(() => { |
| 198 | + if (!activeWorkflowId || !isEnabled) return |
| 199 | + if (pendingBeforeRef.current !== null) { |
| 200 | + flushPending() |
| 201 | + } |
| 202 | + const entry = useCodeUndoRedoStore.getState().redo(activeWorkflowId, blockId, subBlockId) |
| 203 | + if (!entry) return |
| 204 | + logger.debug('Redo code edit', { blockId, subBlockId }) |
| 205 | + applyValue(entry.after) |
| 206 | + }, [activeWorkflowId, applyValue, blockId, flushPending, isEnabled, subBlockId]) |
| 207 | + |
| 208 | + useEffect(() => { |
| 209 | + if (isApplyingRef.current || isStreaming) return |
| 210 | + |
| 211 | + const nextValue = value ?? '' |
| 212 | + |
| 213 | + if (pendingBeforeRef.current !== null) { |
| 214 | + if (pendingAfterRef.current !== nextValue) { |
| 215 | + clearTimer() |
| 216 | + resetPending() |
| 217 | + lastCommittedValueRef.current = nextValue |
| 218 | + } |
| 219 | + return |
| 220 | + } |
| 221 | + |
| 222 | + lastCommittedValueRef.current = nextValue |
| 223 | + }, [clearTimer, isStreaming, resetPending, value]) |
| 224 | + |
| 225 | + useEffect(() => { |
| 226 | + return () => { |
| 227 | + flushPending() |
| 228 | + } |
| 229 | + }, [flushPending]) |
| 230 | + |
| 231 | + return { |
| 232 | + recordChange, |
| 233 | + recordReplace, |
| 234 | + flushPending, |
| 235 | + startSession, |
| 236 | + undo, |
| 237 | + redo, |
| 238 | + } |
| 239 | +} |
0 commit comments