diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 0370e4e..31b2c39 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -39,7 +39,7 @@ import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail' import ShareModal from '@/components/editor/ShareModal' -import ActivityHistorySidebar from '@/components/editor/ActivityHistorySidebar' +import ActivityHistorySidebar, { type AuditEntry } from '@/components/editor/ActivityHistorySidebar' import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' @@ -316,6 +316,19 @@ function getUserColor(userId: string): string { return RANDOM_COLORS[Math.abs(hash) % RANDOM_COLORS.length] } +// Determine the action type for a revert audit entry +function getRevertActionType(originalActionType: string): string { + switch (originalActionType) { + case 'node_add': return 'node_delete' // reverting an add = deleting + case 'node_delete': return 'node_add' // reverting a delete = adding back + case 'node_update': return 'node_update' // reverting an update = updating back + case 'edge_add': return 'edge_delete' + case 'edge_delete': return 'edge_add' + case 'edge_update': return 'edge_update' + default: return originalActionType + } +} + // Compute auto-migration of existing free-text values to character/variable definitions function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { if (!shouldMigrate) { @@ -533,6 +546,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const [nodeLocks, setNodeLocks] = useState>(new Map()) const localLockRef = useRef(null) // node ID currently locked by this user const lockExpiryTimerRef = useRef | null>(null) + const isRevertingRef = useRef(false) // guards against double audit recording during revert // Initialize CRDT manager and connect to Supabase Realtime channel on mount useEffect(() => { @@ -666,13 +680,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, useEffect(() => { if (isRemoteUpdateRef.current) return crdtRef.current?.updateNodes(nodesForCRDT) - auditRef.current?.recordNodeChanges(nodesForCRDT) + if (!isRevertingRef.current) { + auditRef.current?.recordNodeChanges(nodesForCRDT) + } }, [nodesForCRDT]) useEffect(() => { if (isRemoteUpdateRef.current) return crdtRef.current?.updateEdges(edgesForCRDT) - auditRef.current?.recordEdgeChanges(edgesForCRDT) + if (!isRevertingRef.current) { + auditRef.current?.recordEdgeChanges(edgesForCRDT) + } }, [edgesForCRDT]) // Broadcast cursor position on mouse move (throttled to 50ms) @@ -766,6 +784,105 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, } }, [setNodes, setEdges]) + const handleRevertEntry = useCallback((entry: AuditEntry) => { + const { action_type, entity_id, previous_state, new_state } = entry + + // Guard against double audit recording - we write our own audit entry for the revert + isRevertingRef.current = true + // Reset after React has processed the state updates + setTimeout(() => { isRevertingRef.current = false }, 0) + + if (action_type === 'node_add') { + // Revert an addition = delete the node + setNodes((nds) => nds.filter((n) => n.id !== entity_id)) + // Also remove any edges connected to this node + setEdges((eds) => eds.filter((e) => e.source !== entity_id && e.target !== entity_id)) + } else if (action_type === 'node_update') { + // Revert an update = restore previous state + if (previous_state) { + const prevNode = previous_state as unknown as FlowchartNode + setNodes((nds) => + nds.map((n) => + n.id === entity_id + ? { ...n, position: prevNode.position, data: prevNode.data } + : n + ) + ) + } + } else if (action_type === 'node_delete') { + // Revert a deletion = re-create the node from previous_state + if (previous_state) { + const prevNode = previous_state as unknown as FlowchartNode + const newReactFlowNode: Node = { + id: prevNode.id, + type: prevNode.type, + position: prevNode.position, + data: prevNode.data, + } + setNodes((nds) => [...nds, newReactFlowNode]) + } + } else if (action_type === 'edge_add') { + // Revert an edge addition = delete the edge + setEdges((eds) => eds.filter((e) => e.id !== entity_id)) + } else if (action_type === 'edge_update') { + // Revert an edge update = restore previous state + if (previous_state) { + const prevEdge = previous_state as unknown as FlowchartEdge + setEdges((eds) => + eds.map((e) => + e.id === entity_id + ? { + ...e, + source: prevEdge.source, + sourceHandle: prevEdge.sourceHandle, + target: prevEdge.target, + targetHandle: prevEdge.targetHandle, + data: prevEdge.data, + } + : e + ) + ) + } + } else if (action_type === 'edge_delete') { + // Revert an edge deletion = re-create the edge from previous_state + if (previous_state) { + const prevEdge = previous_state as unknown as FlowchartEdge + const newReactFlowEdge: Edge = { + id: prevEdge.id, + source: prevEdge.source, + sourceHandle: prevEdge.sourceHandle, + target: prevEdge.target, + targetHandle: prevEdge.targetHandle, + data: prevEdge.data, + type: 'conditional', + markerEnd: { type: MarkerType.ArrowClosed }, + } + setEdges((eds) => [...eds, newReactFlowEdge]) + } + } + + // Record the revert as a new audit trail entry (fire-and-forget) + const supabase = createClient() + const revertActionType = getRevertActionType(action_type) + supabase + .from('audit_trail') + .insert({ + project_id: projectId, + user_id: userId, + action_type: revertActionType, + entity_id, + previous_state: new_state, // "previous" for the revert is the current state (which was new_state) + new_state: previous_state, // "new" for the revert is what we're restoring to + }) + .then(({ error }) => { + if (error) { + console.error('[Revert] Failed to write revert audit entry:', error) + } + }) + + setToast({ message: 'Change reverted successfully', type: 'success' }) + }, [setNodes, setEdges, projectId, userId]) + const handleNodeFocus = useCallback((nodeId: string) => { // Broadcast lock for this node localLockRef.current = nodeId @@ -1426,6 +1543,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, projectId={projectId} onClose={() => setShowHistory(false)} onSelectEntity={handleHistorySelectEntity} + onRevert={handleRevertEntry} /> )} diff --git a/src/components/editor/ActivityHistorySidebar.tsx b/src/components/editor/ActivityHistorySidebar.tsx index c7ae703..3649428 100644 --- a/src/components/editor/ActivityHistorySidebar.tsx +++ b/src/components/editor/ActivityHistorySidebar.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createClient } from '@/lib/supabase/client' +import RevertConfirmDialog from './RevertConfirmDialog' const PAGE_SIZE = 20 -type AuditEntry = { +export type AuditEntry = { id: string project_id: string user_id: string @@ -21,6 +22,7 @@ type ActivityHistorySidebarProps = { projectId: string onClose: () => void onSelectEntity: (entityId: string, actionType: string) => void + onRevert: (entry: AuditEntry) => void } const ACTION_LABELS: Record = { @@ -132,12 +134,14 @@ export default function ActivityHistorySidebar({ projectId, onClose, onSelectEntity, + onRevert, }: ActivityHistorySidebarProps) { const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [hasMore, setHasMore] = useState(true) const [error, setError] = useState(null) + const [revertEntry, setRevertEntry] = useState(null) const mountedRef = useRef(true) const fetchEntriesRaw = useCallback(async (offset: number): Promise<{ entries: AuditEntry[]; hasMore: boolean; error?: string }> => { @@ -267,13 +271,11 @@ export default function ActivityHistorySidebar({ const userColor = getUserColor(entry.user_id) const isDeleted = entry.action_type.endsWith('_delete') return ( -
{getEntityDescription(entry)}
-
- {entry.user_display_name} - · - {formatTime(entry.created_at)} +
+
+ {entry.user_display_name} + · + {formatTime(entry.created_at)} +
+
- + ) })} @@ -321,6 +339,19 @@ export default function ActivityHistorySidebar({ )} + {revertEntry && ( + { + onRevert(revertEntry) + setRevertEntry(null) + }} + onCancel={() => setRevertEntry(null)} + /> + )} ) } diff --git a/src/components/editor/RevertConfirmDialog.tsx b/src/components/editor/RevertConfirmDialog.tsx new file mode 100644 index 0000000..54cdc1f --- /dev/null +++ b/src/components/editor/RevertConfirmDialog.tsx @@ -0,0 +1,162 @@ +'use client' + +type RevertConfirmDialogProps = { + actionType: string + entityDescription: string + previousState: Record | null + newState: Record | null + onConfirm: () => void + onCancel: () => void +} + +const ACTION_LABELS: Record = { + node_add: 'node addition', + node_update: 'node update', + node_delete: 'node deletion', + edge_add: 'edge addition', + edge_update: 'edge update', + edge_delete: 'edge deletion', +} + +function formatStatePreview(state: Record | null): string { + if (!state) return '(none)' + + const type = state.type as string | undefined + const data = state.data as Record | undefined + + if (type === 'dialogue' && data) { + const speaker = (data.speaker as string) || '(no speaker)' + const text = (data.text as string) || '(no text)' + return `Dialogue: ${speaker}\n"${text.slice(0, 80)}${text.length > 80 ? '…' : ''}"` + } + if (type === 'choice' && data) { + const prompt = (data.prompt as string) || '(no prompt)' + const options = (data.options as { label: string }[]) || [] + const optionLabels = options.map((o) => o.label || '(empty)').join(', ') + return `Choice: ${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}\nOptions: ${optionLabels}` + } + if (type === 'variable' && data) { + const name = (data.variableName as string) || '(unnamed)' + const op = (data.operation as string) || 'set' + const val = data.value ?? 0 + return `Variable: ${name} ${op} ${val}` + } + + // Edge state + if (state.source && state.target) { + const condition = (state.data as Record | undefined)?.condition as Record | undefined + if (condition) { + return `Edge: ${state.source} → ${state.target}\nCondition: ${condition.variableName} ${condition.operator} ${condition.value}` + } + return `Edge: ${state.source} → ${state.target}` + } + + return JSON.stringify(state, null, 2).slice(0, 200) +} + +function getRevertDescription(actionType: string): string { + switch (actionType) { + case 'node_add': + return 'This will delete the node that was added.' + case 'node_update': + return 'This will restore the node to its previous state.' + case 'node_delete': + return 'This will re-create the deleted node.' + case 'edge_add': + return 'This will delete the edge that was added.' + case 'edge_update': + return 'This will restore the edge to its previous state.' + case 'edge_delete': + return 'This will re-create the deleted edge.' + default: + return 'This will undo the change.' + } +} + +export default function RevertConfirmDialog({ + actionType, + entityDescription, + previousState, + newState, + onConfirm, + onCancel, +}: RevertConfirmDialogProps) { + const label = ACTION_LABELS[actionType] || actionType + const description = getRevertDescription(actionType) + + // For revert, the "before" is the current state (newState) and "after" is what we're restoring to (previousState) + const isAddition = actionType.endsWith('_add') + const isDeletion = actionType.endsWith('_delete') + + return ( +
+
e.stopPropagation()} + > +
+

+ Revert {label} +

+

+ {entityDescription} +

+
+ +
+

+ {description} +

+ +
+ {!isAddition && ( +
+
+ Current State +
+
+                  {formatStatePreview(newState)}
+                
+
+ )} + {!isDeletion && ( +
+
+ {isAddition ? 'Will be removed' : 'Restored State'} +
+
+                  {isAddition ? formatStatePreview(newState) : formatStatePreview(previousState)}
+                
+
+ )} + {isDeletion && ( +
+
+ Will be restored +
+
+                  {formatStatePreview(previousState)}
+                
+
+ )} +
+
+ +
+ + +
+
+
+ ) +}