Skip to content

Commit 17b2091

Browse files
committed
feat(code): undo-redo state
1 parent 5189473 commit 17b2091

File tree

6 files changed

+485
-12
lines changed

6 files changed

+485
-12
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import { normalizeName } from '@/executor/constants'
3939
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
4040
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
4141
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
42+
import { useCodeUndoRedo } from '@/hooks/use-code-undo-redo'
43+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
4244

4345
const logger = createLogger('Code')
4446

@@ -212,16 +214,19 @@ export const Code = memo(function Code({
212214
const handleStreamStartRef = useRef<() => void>(() => {})
213215
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
214216
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
215-
const hasEditedSinceFocusRef = useRef(false)
216217
const codeRef = useRef(code)
217218
codeRef.current = code
218219

219220
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
220221
const emitTagSelection = useTagSelection(blockId, subBlockId)
221222
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
222223
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
224+
const blockType = useWorkflowStore(
225+
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
226+
)
223227

224228
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
229+
const isFunctionCode = blockType === 'function' && subBlockId === 'code'
225230

226231
const trimmedCode = code.trim()
227232
const containsReferencePlaceholders =
@@ -296,6 +301,16 @@ export const Code = memo(function Code({
296301
const updatePromptValue = wandHook?.updatePromptValue || (() => {})
297302
const cancelGeneration = wandHook?.cancelGeneration || (() => {})
298303

304+
const { recordChange, clearHistory, recordReplace, flushPending, startSession, undo, redo } =
305+
useCodeUndoRedo({
306+
blockId,
307+
subBlockId,
308+
value: code,
309+
enabled: isFunctionCode,
310+
isReadOnly: readOnly || disabled || isPreview,
311+
isStreaming: isAiStreaming,
312+
})
313+
299314
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, {
300315
isStreaming: isAiStreaming,
301316
onStreamingEnd: () => {
@@ -347,9 +362,10 @@ export const Code = memo(function Code({
347362
setCode(generatedCode)
348363
if (!isPreview && !disabled) {
349364
setStoreValue(generatedCode)
365+
recordReplace(generatedCode)
350366
}
351367
}
352-
}, [isPreview, disabled, setStoreValue])
368+
}, [disabled, isPreview, recordReplace, setStoreValue])
353369

354370
useEffect(() => {
355371
if (!editorRef.current) return
@@ -492,7 +508,7 @@ export const Code = memo(function Code({
492508

493509
setCode(newValue)
494510
setStoreValue(newValue)
495-
hasEditedSinceFocusRef.current = true
511+
recordChange(newValue)
496512
const newCursorPosition = dropPosition + 1
497513
setCursorPosition(newCursorPosition)
498514

@@ -521,7 +537,7 @@ export const Code = memo(function Code({
521537
if (!isPreview && !readOnly) {
522538
setCode(newValue)
523539
emitTagSelection(newValue)
524-
hasEditedSinceFocusRef.current = true
540+
recordChange(newValue)
525541
}
526542
setShowTags(false)
527543
setActiveSourceBlockId(null)
@@ -539,7 +555,7 @@ export const Code = memo(function Code({
539555
if (!isPreview && !readOnly) {
540556
setCode(newValue)
541557
emitTagSelection(newValue)
542-
hasEditedSinceFocusRef.current = true
558+
recordChange(newValue)
543559
}
544560
setShowEnvVars(false)
545561

@@ -625,9 +641,9 @@ export const Code = memo(function Code({
625641
const handleValueChange = useCallback(
626642
(newCode: string) => {
627643
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
628-
hasEditedSinceFocusRef.current = true
629644
setCode(newCode)
630645
setStoreValue(newCode)
646+
recordChange(newCode)
631647

632648
const textarea = editorRef.current?.querySelector('textarea')
633649
if (textarea) {
@@ -646,7 +662,7 @@ export const Code = memo(function Code({
646662
}
647663
}
648664
},
649-
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
665+
[isAiStreaming, isPreview, disabled, readOnly, recordChange, setStoreValue]
650666
)
651667

652668
const handleKeyDown = useCallback(
@@ -657,21 +673,39 @@ export const Code = memo(function Code({
657673
}
658674
if (isAiStreaming) {
659675
e.preventDefault()
676+
return
660677
}
661-
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
678+
if (!isFunctionCode) return
679+
const isUndo = (e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && !e.shiftKey
680+
const isRedo =
681+
((e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && e.shiftKey) ||
682+
(e.key === 'y' && (e.metaKey || e.ctrlKey))
683+
if (isUndo) {
662684
e.preventDefault()
685+
e.stopPropagation()
686+
undo()
687+
return
688+
}
689+
if (isRedo) {
690+
e.preventDefault()
691+
e.stopPropagation()
692+
redo()
663693
}
664694
},
665-
[isAiStreaming]
695+
[isAiStreaming, isFunctionCode, redo, undo]
666696
)
667697

668698
const handleEditorFocus = useCallback(() => {
669-
hasEditedSinceFocusRef.current = false
699+
startSession(codeRef.current)
670700
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
671701
setShowTags(true)
672702
setCursorPosition(0)
673703
}
674-
}, [isPreview, disabled, readOnly])
704+
}, [disabled, isPreview, readOnly, startSession])
705+
706+
const handleEditorBlur = useCallback(() => {
707+
flushPending()
708+
}, [flushPending])
675709

676710
/**
677711
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
@@ -791,6 +825,7 @@ export const Code = memo(function Code({
791825
onValueChange={handleValueChange}
792826
onKeyDown={handleKeyDown}
793827
onFocus={handleEditorFocus}
828+
onBlur={handleEditorBlur}
794829
highlight={highlightCode}
795830
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
796831
/>
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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 = 300,
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+
45+
const lastCommittedValueRef = useRef<string>(value ?? '')
46+
const pendingBeforeRef = useRef<string | null>(null)
47+
const pendingAfterRef = useRef<string | null>(null)
48+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
49+
const isApplyingRef = useRef(false)
50+
51+
const clearTimer = useCallback(() => {
52+
if (timeoutRef.current) {
53+
clearTimeout(timeoutRef.current)
54+
timeoutRef.current = null
55+
}
56+
}, [])
57+
58+
const resetPending = useCallback(() => {
59+
pendingBeforeRef.current = null
60+
pendingAfterRef.current = null
61+
}, [])
62+
63+
const commitPending = useCallback(() => {
64+
if (!isEnabled || !activeWorkflowId) {
65+
clearTimer()
66+
resetPending()
67+
return
68+
}
69+
70+
const before = pendingBeforeRef.current
71+
const after = pendingAfterRef.current
72+
if (before === null || after === null) return
73+
74+
if (before === after) {
75+
lastCommittedValueRef.current = after
76+
clearTimer()
77+
resetPending()
78+
return
79+
}
80+
81+
useCodeUndoRedoStore.getState().push({
82+
id: crypto.randomUUID(),
83+
createdAt: Date.now(),
84+
workflowId: activeWorkflowId,
85+
blockId,
86+
subBlockId,
87+
before,
88+
after,
89+
})
90+
91+
lastCommittedValueRef.current = after
92+
clearTimer()
93+
resetPending()
94+
}, [activeWorkflowId, blockId, clearTimer, isEnabled, resetPending, subBlockId])
95+
96+
const recordChange = useCallback(
97+
(nextValue: string) => {
98+
if (!isEnabled || isApplyingRef.current) return
99+
100+
if (pendingBeforeRef.current === null) {
101+
pendingBeforeRef.current = lastCommittedValueRef.current ?? ''
102+
}
103+
104+
pendingAfterRef.current = nextValue
105+
clearTimer()
106+
timeoutRef.current = setTimeout(commitPending, debounceMs)
107+
},
108+
[clearTimer, commitPending, debounceMs, isEnabled]
109+
)
110+
111+
const recordReplace = useCallback(
112+
(nextValue: string) => {
113+
if (!isEnabled || isApplyingRef.current || !activeWorkflowId) return
114+
115+
if (pendingBeforeRef.current !== null) {
116+
commitPending()
117+
}
118+
119+
const before = lastCommittedValueRef.current ?? ''
120+
if (before === nextValue) {
121+
lastCommittedValueRef.current = nextValue
122+
resetPending()
123+
return
124+
}
125+
126+
useCodeUndoRedoStore.getState().push({
127+
id: crypto.randomUUID(),
128+
createdAt: Date.now(),
129+
workflowId: activeWorkflowId,
130+
blockId,
131+
subBlockId,
132+
before,
133+
after: nextValue,
134+
})
135+
136+
lastCommittedValueRef.current = nextValue
137+
clearTimer()
138+
resetPending()
139+
},
140+
[activeWorkflowId, blockId, clearTimer, commitPending, isEnabled, resetPending, subBlockId]
141+
)
142+
143+
const clearHistory = useCallback(
144+
(nextValue?: string) => {
145+
if (!activeWorkflowId) return
146+
clearTimer()
147+
resetPending()
148+
useCodeUndoRedoStore.getState().clear(activeWorkflowId, blockId, subBlockId)
149+
if (nextValue !== undefined) {
150+
lastCommittedValueRef.current = nextValue
151+
}
152+
},
153+
[activeWorkflowId, blockId, clearTimer, resetPending, subBlockId]
154+
)
155+
156+
const flushPending = useCallback(() => {
157+
if (!pendingBeforeRef.current) return
158+
clearTimer()
159+
commitPending()
160+
}, [clearTimer, commitPending])
161+
162+
const startSession = useCallback(
163+
(currentValue: string) => {
164+
clearTimer()
165+
resetPending()
166+
lastCommittedValueRef.current = currentValue ?? ''
167+
},
168+
[clearTimer, resetPending]
169+
)
170+
171+
const applyValue = useCallback(
172+
(nextValue: string) => {
173+
if (!isEnabled) return
174+
isApplyingRef.current = true
175+
try {
176+
collaborativeSetSubblockValue(blockId, subBlockId, nextValue)
177+
} finally {
178+
isApplyingRef.current = false
179+
}
180+
lastCommittedValueRef.current = nextValue
181+
clearTimer()
182+
resetPending()
183+
},
184+
[blockId, clearTimer, collaborativeSetSubblockValue, isEnabled, resetPending, subBlockId]
185+
)
186+
187+
const undo = useCallback(() => {
188+
if (!activeWorkflowId || !isEnabled) return
189+
const entry = useCodeUndoRedoStore.getState().undo(activeWorkflowId, blockId, subBlockId)
190+
if (!entry) return
191+
logger.debug('Undo code edit', { blockId, subBlockId })
192+
applyValue(entry.before)
193+
}, [activeWorkflowId, applyValue, blockId, isEnabled, subBlockId])
194+
195+
const redo = useCallback(() => {
196+
if (!activeWorkflowId || !isEnabled) return
197+
const entry = useCodeUndoRedoStore.getState().redo(activeWorkflowId, blockId, subBlockId)
198+
if (!entry) return
199+
logger.debug('Redo code edit', { blockId, subBlockId })
200+
applyValue(entry.after)
201+
}, [activeWorkflowId, applyValue, blockId, isEnabled, subBlockId])
202+
203+
useEffect(() => {
204+
if (!pendingBeforeRef.current && !isApplyingRef.current) {
205+
lastCommittedValueRef.current = value ?? ''
206+
}
207+
}, [value])
208+
209+
return {
210+
recordChange,
211+
clearHistory,
212+
recordReplace,
213+
flushPending,
214+
startSession,
215+
undo,
216+
redo,
217+
}
218+
}

apps/sim/hooks/use-collaborative-workflow.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
import { useNotificationStore } from '@/stores/notifications'
2121
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
2222
import { usePanelEditorStore, useVariablesStore } from '@/stores/panel'
23-
import { useUndoRedoStore } from '@/stores/undo-redo'
23+
import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo'
2424
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
2525
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2626
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -449,6 +449,10 @@ export function useCollaborativeWorkflow() {
449449
try {
450450
// The setValue function automatically uses the active workflow ID
451451
useSubBlockStore.getState().setValue(blockId, subblockId, value)
452+
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
453+
if (activeWorkflowId && blockType === 'function' && subblockId === 'code') {
454+
useCodeUndoRedoStore.getState().clear(activeWorkflowId, blockId, subblockId)
455+
}
452456
} catch (error) {
453457
logger.error('Error applying remote subblock update:', error)
454458
} finally {

0 commit comments

Comments
 (0)