|
@@ -31,6 +31,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from 'react'
|
|
|
import { AlertTriangle } from 'lucide-react'
|
|
import { AlertTriangle } from 'lucide-react'
|
|
|
import { wsClient } from '../api/client'
|
|
import { wsClient } from '../api/client'
|
|
|
import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
|
|
import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
|
|
|
|
|
+import { diffWorkflows, mergeWorkflows, summariseChanges, type Change } from '../utils/workflowDiff'
|
|
|
import type { AvailableField } from '../components/ConditionBuilder'
|
|
import type { AvailableField } from '../components/ConditionBuilder'
|
|
|
import type { FieldInfo } from '../components/AvailableDataPanel'
|
|
import type { FieldInfo } from '../components/AvailableDataPanel'
|
|
|
import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
|
|
import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
|
|
@@ -350,6 +351,13 @@ function WorkflowEditorInner() {
|
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
|
|
const [showNodePicker, setShowNodePicker] = useState(false)
|
|
const [showNodePicker, setShowNodePicker] = useState(false)
|
|
|
const [hasChanges, setHasChanges] = useState(false)
|
|
const [hasChanges, setHasChanges] = useState(false)
|
|
|
|
|
+ // Read by the sync effect, which must see the value as it is now rather than
|
|
|
|
|
+ // as it was when the effect was created.
|
|
|
|
|
+ const hasChangesRef = useRef(hasChanges)
|
|
|
|
|
+ hasChangesRef.current = hasChanges
|
|
|
|
|
+ // Set only by the merge path: the graph about to be loaded is not the stored
|
|
|
|
|
+ // one, so the canvas has unsaved work the moment it appears.
|
|
|
|
|
+ const loadedIsUnsavedRef = useRef(false)
|
|
|
const [selectedNodeData, setSelectedNodeData] = useState<SelectedNodeData | null>(null)
|
|
const [selectedNodeData, setSelectedNodeData] = useState<SelectedNodeData | null>(null)
|
|
|
const [showNodeConfig, setShowNodeConfig] = useState(false)
|
|
const [showNodeConfig, setShowNodeConfig] = useState(false)
|
|
|
const [editingConfig, setEditingConfig] = useState<Record<string, any>>({})
|
|
const [editingConfig, setEditingConfig] = useState<Record<string, any>>({})
|
|
@@ -563,17 +571,117 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
|
|
// The version currently on the canvas. Kept in a ref because saving reads it
|
|
// The version currently on the canvas. Kept in a ref because saving reads it
|
|
|
// and must not be re-created every time it changes.
|
|
// and must not be re-created every time it changes.
|
|
|
- const { others: otherViewers, savedByOther, dismissSavedByOther } = useWorkflowPresence(id)
|
|
|
|
|
|
|
+ // What the other tab or the other person actually did. Fetched only when
|
|
|
|
|
+ // there is something to explain, not kept in step continuously.
|
|
|
|
|
+ const [incomingChanges, setIncomingChanges] = useState<Change[] | null>(null)
|
|
|
|
|
+ const [myChanges, setMyChanges] = useState<Change[] | null>(null)
|
|
|
|
|
+ // Where both sides changed the same thing and this canvas kept its own.
|
|
|
|
|
+ const [conflicts, setConflicts] = useState<string[]>([])
|
|
|
|
|
+ const [showChanges, setShowChanges] = useState(false)
|
|
|
|
|
+ // Whether those changes were taken onto this canvas automatically, or are
|
|
|
|
|
+ // still only a warning because taking them would have destroyed something.
|
|
|
|
|
+ const [autoSynced, setAutoSynced] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const loadedVersionRef = useRef<number | null>(null)
|
|
|
|
|
+ const { others: otherViewers, myOtherTabs, savedByOther, dismissSavedByOther } =
|
|
|
|
|
+ useWorkflowPresence(id, loadedVersionRef)
|
|
|
// The save handler needs the latest value without being rebuilt for it.
|
|
// The save handler needs the latest value without being rebuilt for it.
|
|
|
const savedByOtherRef = useRef(savedByOther)
|
|
const savedByOtherRef = useRef(savedByOther)
|
|
|
savedByOtherRef.current = savedByOther
|
|
savedByOtherRef.current = savedByOther
|
|
|
|
|
|
|
|
- const loadedVersionRef = useRef<number | null>(null)
|
|
|
|
|
- const [saveConflict, setSaveConflict] = useState<{ username: string; currentVersion: number } | null>(null)
|
|
|
|
|
|
|
+ const [saveConflict, setSaveConflict] = useState<{ username: string; isSelf: boolean; currentVersion: number } | null>(null)
|
|
|
|
|
+ // The workflow as this canvas received it. Everything the user has done since
|
|
|
|
|
+ // is the difference between this and the canvas, and everything the other tab
|
|
|
|
|
+ // did is the difference between this and what is now stored. Without it, an
|
|
|
|
|
+ // unsaved node of your own is indistinguishable from a node somebody else
|
|
|
|
|
+ // deleted - and gets reported as the latter.
|
|
|
|
|
+ const baselineRef = useRef<{ name?: string; nodes: any[]; connections: any[]; settings?: any } | null>(null)
|
|
|
|
|
+ // Which stored version the baseline describes. A merge puts a graph into the
|
|
|
|
|
+ // cache that is deliberately NOT the stored one, and without this the
|
|
|
|
|
+ // baseline would follow it and every later comparison would be measured from
|
|
|
|
|
+ // the wrong place.
|
|
|
|
|
+ const baselineVersionRef = useRef<number | null>(null)
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (typeof workflow?.version === 'number') loadedVersionRef.current = workflow.version
|
|
|
|
|
|
|
+ if (!workflow) return
|
|
|
|
|
+ if (typeof workflow.version === 'number') loadedVersionRef.current = workflow.version
|
|
|
|
|
+ if (baselineVersionRef.current === (workflow.version ?? null)) return
|
|
|
|
|
+ baselineVersionRef.current = workflow.version ?? null
|
|
|
|
|
+ baselineRef.current = {
|
|
|
|
|
+ name: workflow.name,
|
|
|
|
|
+ nodes: workflow.nodes || [],
|
|
|
|
|
+ connections: workflow.connections || [],
|
|
|
|
|
+ settings: workflow.settings || {},
|
|
|
|
|
+ }
|
|
|
}, [workflow])
|
|
}, [workflow])
|
|
|
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!id || (!savedByOther && !saveConflict)) {
|
|
|
|
|
+ setIncomingChanges(null)
|
|
|
|
|
+ setMyChanges(null)
|
|
|
|
|
+ setConflicts([])
|
|
|
|
|
+ setShowChanges(false)
|
|
|
|
|
+ setAutoSynced(false)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ let cancelled = false
|
|
|
|
|
+ // currentGraphRef rather than currentGraph, so this does not re-run and
|
|
|
|
|
+ // re-fetch on every keystroke on the canvas.
|
|
|
|
|
+ workflowsApi
|
|
|
|
|
+ .get(id)
|
|
|
|
|
+ .then((stored) => {
|
|
|
|
|
+ if (cancelled) return
|
|
|
|
|
+
|
|
|
|
|
+ // Diff first. Adopting the stored version replaces the canvas, and
|
|
|
|
|
+ // after that there is nothing left to compare it against.
|
|
|
|
|
+ //
|
|
|
|
|
+ // Both sides are measured against the version this canvas started
|
|
|
|
|
+ // from, not against each other, so "they added a node" and "you added
|
|
|
|
|
+ // a node" stay separate facts rather than one confusing one.
|
|
|
|
|
+ const baseline = baselineRef.current ?? { nodes: [], connections: [] }
|
|
|
|
|
+ const mineNow = currentGraphRef.current()
|
|
|
|
|
+ setIncomingChanges(diffWorkflows(baseline as any, stored as any))
|
|
|
|
|
+ setMyChanges(diffWorkflows(baseline as any, mineNow))
|
|
|
|
|
+
|
|
|
|
|
+ // Every tab takes the other side's work, whether or not it has unsaved
|
|
|
|
|
+ // work of its own. Leaving a canvas behind is what made an accidental
|
|
|
|
|
+ // overwrite possible in the first place: once this one holds both sets
|
|
|
|
|
+ // of changes, saving it cannot lose either.
|
|
|
|
|
+ const merge = mergeWorkflows(baseline as any, mineNow, stored as any)
|
|
|
|
|
+ setConflicts(merge.conflicts)
|
|
|
|
|
+ setAutoSynced(true)
|
|
|
|
|
+
|
|
|
|
|
+ // The baseline is the STORED version, not the merged one - that is the
|
|
|
|
|
+ // point the next comparison has to be measured from.
|
|
|
|
|
+ baselineVersionRef.current = stored.version ?? null
|
|
|
|
|
+ baselineRef.current = {
|
|
|
|
|
+ name: stored.name,
|
|
|
|
|
+ nodes: stored.nodes || [],
|
|
|
|
|
+ connections: stored.connections || [],
|
|
|
|
|
+ settings: stored.settings || {},
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof stored.version === 'number') loadedVersionRef.current = stored.version
|
|
|
|
|
+
|
|
|
|
|
+ // Carrying the stored version on the merged record is what lets the
|
|
|
|
|
+ // next save through: it is no longer based on a version that has been
|
|
|
|
|
+ // overtaken.
|
|
|
|
|
+ // Seeding the cache runs the loader below, which is what puts the
|
|
|
|
|
+ // merged graph on the canvas - and which needs telling that this
|
|
|
|
|
+ // particular load is not a clean one.
|
|
|
|
|
+ loadedIsUnsavedRef.current = merge.differsFromStored
|
|
|
|
|
+ queryClient.setQueryData(['workflow', id], { ...stored, ...merge.graph })
|
|
|
|
|
+ setHasChanges(merge.differsFromStored)
|
|
|
|
|
+ // The refusal has been dealt with - what it was protecting is now
|
|
|
|
|
+ // folded in, and pressing save again is the right move.
|
|
|
|
|
+ setSaveConflict(null)
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {
|
|
|
|
|
+ // The comparison is a convenience; failing to fetch it must not take
|
|
|
|
|
+ // the warning itself away.
|
|
|
|
|
+ if (!cancelled) { setIncomingChanges(null); setMyChanges(null) }
|
|
|
|
|
+ })
|
|
|
|
|
+ return () => { cancelled = true }
|
|
|
|
|
+ }, [id, savedByOther, saveConflict])
|
|
|
|
|
+
|
|
|
const { data: nodeDefinitions } = useQuery({
|
|
const { data: nodeDefinitions } = useQuery({
|
|
|
queryKey: ['nodes'],
|
|
queryKey: ['nodes'],
|
|
|
queryFn: () => nodesApi.list(),
|
|
queryFn: () => nodesApi.list(),
|
|
@@ -751,11 +859,15 @@ function WorkflowEditorInner() {
|
|
|
// to it rather than being told "failed" and left to guess.
|
|
// to it rather than being told "failed" and left to guess.
|
|
|
const conflict = error?.response?.status === 409 ? error.response.data : null
|
|
const conflict = error?.response?.status === 409 ? error.response.data : null
|
|
|
if (conflict) {
|
|
if (conflict) {
|
|
|
|
|
+ const other = savedByOtherRef.current
|
|
|
setSaveConflict({
|
|
setSaveConflict({
|
|
|
- username: conflict.current?.updatedByUsername || savedByOtherRef.current?.username || 'Someone else',
|
|
|
|
|
|
|
+ username: other?.username || 'Someone else',
|
|
|
|
|
+ isSelf: other?.isSelf ?? false,
|
|
|
currentVersion: Number(conflict.currentVersion) || 0,
|
|
currentVersion: Number(conflict.currentVersion) || 0,
|
|
|
})
|
|
})
|
|
|
- showToast('error', 'Someone else saved this workflow while you were editing it')
|
|
|
|
|
|
|
+ showToast('error', other?.isSelf
|
|
|
|
|
+ ? 'You saved this workflow in another tab'
|
|
|
|
|
+ : 'Someone else saved this workflow while you were editing it')
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
showToast('error', `Failed to save: ${error.message}`)
|
|
showToast('error', `Failed to save: ${error.message}`)
|
|
@@ -1357,7 +1469,13 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
|
|
setNodes(flowNodes)
|
|
setNodes(flowNodes)
|
|
|
setEdges(flowEdges)
|
|
setEdges(flowEdges)
|
|
|
- setHasChanges(false)
|
|
|
|
|
|
|
+ // Loading normally means the canvas matches what is stored. A merge is
|
|
|
|
|
+ // the exception: it loads a graph that deliberately does not, and
|
|
|
|
|
+ // clearing the flag there would present unsaved work as saved - the one
|
|
|
|
|
+ // mistake that actually loses it, because nothing then warns on the way
|
|
|
|
|
+ // out.
|
|
|
|
|
+ setHasChanges(loadedIsUnsavedRef.current)
|
|
|
|
|
+ loadedIsUnsavedRef.current = false
|
|
|
}
|
|
}
|
|
|
}, [workflow, nodeDefs, nodeDefsMap, setNodes, setEdges])
|
|
}, [workflow, nodeDefs, nodeDefsMap, setNodes, setEdges])
|
|
|
|
|
|
|
@@ -1467,8 +1585,11 @@ function WorkflowEditorInner() {
|
|
|
setShowNodePicker(true)
|
|
setShowNodePicker(true)
|
|
|
}, [isViewingExecution, reactFlowInstance])
|
|
}, [isViewingExecution, reactFlowInstance])
|
|
|
|
|
|
|
|
- const handleSave = useCallback(() => {
|
|
|
|
|
- const workflowNodes = nodes.map((node) => ({
|
|
|
|
|
|
|
+ // What is on the canvas, in the shape the workflow is stored in. Saving and
|
|
|
|
|
+ // diffing both need it, and two copies of this mapping would drift.
|
|
|
|
|
+ const currentGraph = useCallback(() => ({
|
|
|
|
|
+ name: workflowName,
|
|
|
|
|
+ nodes: nodes.map((node) => ({
|
|
|
id: node.id,
|
|
id: node.id,
|
|
|
type: node.data.type,
|
|
type: node.data.type,
|
|
|
name: node.data.label,
|
|
name: node.data.label,
|
|
@@ -1479,26 +1600,33 @@ function WorkflowEditorInner() {
|
|
|
// off came back on at the next save, which looks like the editor
|
|
// off came back on at the next save, which looks like the editor
|
|
|
// refusing to do as it is told.
|
|
// refusing to do as it is told.
|
|
|
disabled: node.data.disabled === true,
|
|
disabled: node.data.disabled === true,
|
|
|
- }))
|
|
|
|
|
-
|
|
|
|
|
- const workflowConnections = edges.map((edge) => ({
|
|
|
|
|
|
|
+ })),
|
|
|
|
|
+ connections: edges.map((edge) => ({
|
|
|
sourceNodeId: edge.source,
|
|
sourceNodeId: edge.source,
|
|
|
sourceOutput: edge.sourceHandle || 'main',
|
|
sourceOutput: edge.sourceHandle || 'main',
|
|
|
targetNodeId: edge.target,
|
|
targetNodeId: edge.target,
|
|
|
targetInput: edge.targetHandle || 'data',
|
|
targetInput: edge.targetHandle || 'data',
|
|
|
- }))
|
|
|
|
|
|
|
+ })),
|
|
|
|
|
+ settings: workflowSettings,
|
|
|
|
|
+ }), [nodes, edges, workflowName, workflowSettings])
|
|
|
|
|
+
|
|
|
|
|
+ const currentGraphRef = useRef(currentGraph)
|
|
|
|
|
+ currentGraphRef.current = currentGraph
|
|
|
|
|
+
|
|
|
|
|
+ const handleSave = useCallback(() => {
|
|
|
|
|
+ const graph = currentGraph()
|
|
|
|
|
|
|
|
saveMutation.mutate({
|
|
saveMutation.mutate({
|
|
|
- name: workflowName,
|
|
|
|
|
- nodes: workflowNodes,
|
|
|
|
|
- connections: workflowConnections,
|
|
|
|
|
- settings: workflowSettings,
|
|
|
|
|
|
|
+ name: graph.name,
|
|
|
|
|
+ nodes: graph.nodes,
|
|
|
|
|
+ connections: graph.connections,
|
|
|
|
|
+ settings: graph.settings,
|
|
|
// The version this editor loaded. The server refuses the save if the
|
|
// The version this editor loaded. The server refuses the save if the
|
|
|
// stored workflow has moved on since, rather than overwriting whoever
|
|
// stored workflow has moved on since, rather than overwriting whoever
|
|
|
// got there first.
|
|
// got there first.
|
|
|
expectedVersion: loadedVersionRef.current ?? undefined,
|
|
expectedVersion: loadedVersionRef.current ?? undefined,
|
|
|
} as any)
|
|
} as any)
|
|
|
- }, [nodes, edges, saveMutation, workflowSettings, workflowName])
|
|
|
|
|
|
|
+ }, [currentGraph, saveMutation])
|
|
|
|
|
|
|
|
// Handle workflow rename
|
|
// Handle workflow rename
|
|
|
const handleRename = useCallback((newName: string) => {
|
|
const handleRename = useCallback((newName: string) => {
|
|
@@ -2708,32 +2836,102 @@ function WorkflowEditorInner() {
|
|
|
Both mean the same thing to the person sitting here: what is on the
|
|
Both mean the same thing to the person sitting here: what is on the
|
|
|
canvas is no longer what is stored. */}
|
|
canvas is no longer what is stored. */}
|
|
|
{(saveConflict || savedByOther) && !isViewingExecution && (
|
|
{(saveConflict || savedByOther) && !isViewingExecution && (
|
|
|
- <div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-300 dark:border-amber-700 text-sm text-amber-900 dark:text-amber-200">
|
|
|
|
|
|
|
+ <div className={`px-4 py-2 border-b text-sm ${
|
|
|
|
|
+ autoSynced
|
|
|
|
|
+ ? 'bg-sky-50 dark:bg-sky-900/30 border-sky-300 dark:border-sky-700 text-sky-900 dark:text-sky-200'
|
|
|
|
|
+ : 'bg-amber-50 dark:bg-amber-900/30 border-amber-300 dark:border-amber-700 text-amber-900 dark:text-amber-200'
|
|
|
|
|
+ }`}>
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
|
<span className="flex-1">
|
|
<span className="flex-1">
|
|
|
- {saveConflict
|
|
|
|
|
- ? `${saveConflict.username} saved this workflow while you were editing it, so your save was refused. Your changes are still here - reload to take their version, or copy what you need out first.`
|
|
|
|
|
- : `${savedByOther!.username} just saved this workflow. Your copy is out of date${hasChanges ? ', and you have unsaved changes' : ''}.`}
|
|
|
|
|
|
|
+ {autoSynced
|
|
|
|
|
+ ? (savedByOther?.isSelf
|
|
|
|
|
+ ? 'Brought in the changes you saved in your other tab.'
|
|
|
|
|
+ : `Brought in ${savedByOther?.username ?? 'the other side'}'s changes.`) +
|
|
|
|
|
+ (myChanges && myChanges.length > 0
|
|
|
|
|
+ ? ' Your unsaved changes here are untouched, and are now on top of theirs - save when you are ready.'
|
|
|
|
|
+ : ' This canvas is up to date.')
|
|
|
|
|
+ : saveConflict
|
|
|
|
|
+ ? saveConflict.isSelf
|
|
|
|
|
+ ? `You saved this workflow in another tab, so this save was refused rather than undoing it. Your changes here are still on the canvas - reload to take the other tab's version, or copy what you need out first.`
|
|
|
|
|
+ : `${saveConflict.username} saved this workflow while you were editing it, so your save was refused. Your changes are still here - reload to take their version, or copy what you need out first.`
|
|
|
|
|
+ : savedByOther!.isSelf
|
|
|
|
|
+ ? `You just saved this workflow in another tab. This copy is out of date${hasChanges ? ', and has unsaved changes of its own' : ''}.`
|
|
|
|
|
+ : `${savedByOther!.username} just saved this workflow. Your copy is out of date${hasChanges ? ', and you have unsaved changes' : ''}.`}
|
|
|
</span>
|
|
</span>
|
|
|
- <button
|
|
|
|
|
- onClick={() => {
|
|
|
|
|
- // A reload throws away whatever is on the canvas, so it is a
|
|
|
|
|
- // button somebody presses, never something that happens to them
|
|
|
|
|
- // mid-edit.
|
|
|
|
|
- queryClient.invalidateQueries({ queryKey: ['workflow', id] })
|
|
|
|
|
- window.location.reload()
|
|
|
|
|
- }}
|
|
|
|
|
- className="px-2 py-1 rounded bg-amber-600 text-white hover:bg-amber-700 shrink-0"
|
|
|
|
|
- >
|
|
|
|
|
- Reload
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ {!autoSynced && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ // Only reachable if the merge could not be fetched. A reload
|
|
|
|
|
+ // throws away whatever is on the canvas, so it is a button
|
|
|
|
|
+ // somebody presses, never something that happens to them
|
|
|
|
|
+ // mid-edit.
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['workflow', id] })
|
|
|
|
|
+ window.location.reload()
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="px-2 py-1 rounded bg-amber-600 text-white hover:bg-amber-700 shrink-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ Discard mine, take theirs
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
<button
|
|
<button
|
|
|
onClick={() => { setSaveConflict(null); dismissSavedByOther() }}
|
|
onClick={() => { setSaveConflict(null); dismissSavedByOther() }}
|
|
|
- className="px-2 py-1 rounded hover:bg-amber-100 dark:hover:bg-amber-800 shrink-0"
|
|
|
|
|
|
|
+ className="px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/10 shrink-0"
|
|
|
>
|
|
>
|
|
|
Dismiss
|
|
Dismiss
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* "Your copy is out of date" asks somebody to choose between two
|
|
|
|
|
+ versions without showing them either. This is the difference. */}
|
|
|
|
|
+ {conflicts.length > 0 && (
|
|
|
|
|
+ <div className="mt-1 pl-7 text-amber-900 dark:text-amber-200">
|
|
|
|
|
+ <div className="font-medium">Both sides changed the same thing - this canvas kept its own:</div>
|
|
|
|
|
+ <ul className="space-y-0.5 list-disc list-inside">
|
|
|
|
|
+ {conflicts.map((c, i) => <li key={i}>{c}</li>)}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {incomingChanges !== null && (
|
|
|
|
|
+ <div className="mt-1 pl-7">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setShowChanges((v) => !v)}
|
|
|
|
|
+ className="underline underline-offset-2 hover:no-underline"
|
|
|
|
|
+ disabled={incomingChanges.length === 0 && !(myChanges && myChanges.length)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {autoSynced ? 'Came in' : 'Theirs'}: {summariseChanges(incomingChanges)}
|
|
|
|
|
+ {myChanges && myChanges.length > 0 && (
|
|
|
|
|
+ <> {'\u00b7'} yours, still unsaved: {summariseChanges(myChanges)}</>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {(incomingChanges.length > 0 || (myChanges?.length ?? 0) > 0) &&
|
|
|
|
|
+ (showChanges ? ' - hide' : ' - show')}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {showChanges && (
|
|
|
|
|
+ <div className="mt-1 ml-1 max-h-48 overflow-y-auto space-y-2">
|
|
|
|
|
+ {incomingChanges.length > 0 && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="font-medium">
|
|
|
|
|
+ {autoSynced ? 'Brought in' : 'Saved by the other side'}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <ul className="space-y-0.5 list-disc list-inside">
|
|
|
|
|
+ {incomingChanges.map((c, i) => <li key={i}>{c.text}</li>)}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {myChanges && myChanges.length > 0 && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="font-medium">Still only on this canvas</div>
|
|
|
|
|
+ <ul className="space-y-0.5 list-disc list-inside">
|
|
|
|
|
+ {myChanges.map((c, i) => <li key={i}>{c.text}</li>)}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
{/* Execution Viewer Banner */}
|
|
{/* Execution Viewer Banner */}
|
|
@@ -2773,6 +2971,7 @@ function WorkflowEditorInner() {
|
|
|
onAddNode={() => setShowNodePicker(true)}
|
|
onAddNode={() => setShowNodePicker(true)}
|
|
|
onAutoLayout={autoLayout}
|
|
onAutoLayout={autoLayout}
|
|
|
otherViewers={otherViewers}
|
|
otherViewers={otherViewers}
|
|
|
|
|
+ myOtherTabs={myOtherTabs}
|
|
|
onUndo={() => { if (history.undo()) setHasChanges(true) }}
|
|
onUndo={() => { if (history.undo()) setHasChanges(true) }}
|
|
|
onRedo={() => { if (history.redo()) setHasChanges(true) }}
|
|
onRedo={() => { if (history.redo()) setHasChanges(true) }}
|
|
|
canUndo={history.canUndo}
|
|
canUndo={history.canUndo}
|