diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 573cd369ff..a23717a96a 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -56,6 +56,28 @@ type LoroContainer = { getAttached?: () => unknown } +type GraphImportLogEntry = { + timestamp: number + appId: string | null + sources: Array<'nodes' | 'edges'> + before: { + nodes: Node[] + edges: Edge[] + } + after: { + nodes: Node[] + edges: Edge[] + } + meta: { + leaderId: string | null + isLeader: boolean + graphViewActive: boolean | null + pendingInitialSync: boolean + } +} + +const GRAPH_IMPORT_LOG_LIMIT = 20 + const toLoroValue = (value: unknown): Value => cloneDeep(value) as Value const toLoroRecord = (value: unknown): Record => cloneDeep(value) as Record export class CollaborationManager { @@ -78,6 +100,15 @@ export class CollaborationManager { private rejoinInProgress = false private pendingGraphImportEmit = false private graphViewActive: boolean | null = null + private graphImportLogs: GraphImportLogEntry[] = [] + private pendingImportLog: { + timestamp: number + sources: Set<'nodes' | 'edges'> + before: { + nodes: Node[] + edges: Edge[] + } + } | null = null private getActiveSocket(): Socket | null { if (!this.currentAppId) @@ -504,6 +535,7 @@ export class CollaborationManager { this.onlineUsers = [] this.isUndoRedoInProgress = false this.rejoinInProgress = false + this.clearGraphImportLog() // Only reset leader status when actually disconnecting const wasLeader = this.isLeader @@ -908,6 +940,7 @@ export class CollaborationManager { requestAnimationFrame(() => { const state = reactFlowStore.getState() const previousNodes: Node[] = state.getNodes() + this.startImportLog('nodes', { nodes: previousNodes, edges: state.getEdges() }) const previousNodeMap = new Map(previousNodes.map(node => [node.id, node])) const selectedIds = new Set( previousNodes @@ -964,6 +997,7 @@ export class CollaborationManager { requestAnimationFrame(() => { // Get ReactFlow's native setters, not the collaborative ones const state = reactFlowStore.getState() + this.startImportLog('edges', { nodes: state.getNodes(), edges: state.getEdges() }) const updatedEdges = Array.from(this.edgesMap?.values() || []) as Edge[] this.pendingInitialSync = false @@ -984,6 +1018,7 @@ export class CollaborationManager { this.pendingGraphImportEmit = true requestAnimationFrame(() => { this.pendingGraphImportEmit = false + this.finalizeImportLog() const mergedNodes = this.mergeLocalNodeState(this.getNodes()) this.eventEmitter.emit('graphImport', { nodes: mergedNodes, @@ -1034,6 +1069,98 @@ export class CollaborationManager { }) } + getGraphImportLog(): GraphImportLogEntry[] { + return cloneDeep(this.graphImportLogs) + } + + clearGraphImportLog(): void { + this.graphImportLogs = [] + this.pendingImportLog = null + } + + downloadGraphImportLog(): void { + if (this.graphImportLogs.length === 0) + return + + const payload = { + appId: this.currentAppId, + generatedAt: new Date().toISOString(), + entries: this.graphImportLogs, + } + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + const appSuffix = this.currentAppId ?? 'unknown' + const fileName = `workflow-graph-import-log-${appSuffix}-${stamp}.json` + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) + } + + private snapshotReactFlowGraph(): { nodes: Node[], edges: Edge[] } { + if (!this.reactFlowStore) { + return { + nodes: this.getNodes(), + edges: this.getEdges(), + } + } + + const state = this.reactFlowStore.getState() + return { + nodes: cloneDeep(state.getNodes()), + edges: cloneDeep(state.getEdges()), + } + } + + private startImportLog(source: 'nodes' | 'edges', before?: { nodes: Node[], edges: Edge[] }): void { + if (!this.pendingImportLog) { + const snapshot = before ?? this.snapshotReactFlowGraph() + this.pendingImportLog = { + timestamp: Date.now(), + sources: new Set([source]), + before: { + nodes: cloneDeep(snapshot.nodes), + edges: cloneDeep(snapshot.edges), + }, + } + return + } + this.pendingImportLog.sources.add(source) + } + + private finalizeImportLog(): void { + if (!this.pendingImportLog) + return + + const afterSnapshot = this.snapshotReactFlowGraph() + const entry: GraphImportLogEntry = { + timestamp: this.pendingImportLog.timestamp, + appId: this.currentAppId, + sources: Array.from(this.pendingImportLog.sources), + before: { + nodes: this.pendingImportLog.before.nodes, + edges: this.pendingImportLog.before.edges, + }, + after: { + nodes: cloneDeep(afterSnapshot.nodes), + edges: cloneDeep(afterSnapshot.edges), + }, + meta: { + leaderId: this.leaderId, + isLeader: this.isLeader, + graphViewActive: this.graphViewActive, + pendingInitialSync: this.pendingInitialSync, + }, + } + + this.graphImportLogs.push(entry) + if (this.graphImportLogs.length > GRAPH_IMPORT_LOG_LIMIT) + this.graphImportLogs.splice(0, this.graphImportLogs.length - GRAPH_IMPORT_LOG_LIMIT) + this.pendingImportLog = null + } + private setupSocketEventListeners(socket: Socket): void { socket.on('collaboration_update', (update: CollaborationUpdate) => { if (update.type === 'mouse_move') { diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 6eb7cd2236..7f91d3ab1e 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -10,6 +10,7 @@ import { useWorkflowMoveMode, useWorkflowOrganize, } from '.' +import { collaborationManager } from '../collaboration/core/collaboration-manager' import { useWorkflowStore } from '../store' import { getKeyboardKeyCodeBySystem, @@ -266,6 +267,13 @@ export const useShortcuts = (enabled = true): void => { useCapture: true, }) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.l`, (e) => { + if (shouldHandleShortcut(e)) { + e.preventDefault() + collaborationManager.downloadGraphImportLog() + } + }, { exactMatch: true, useCapture: true }) + // Shift ↓ useKeyPress( 'shift',