Răsfoiți Sursa

feat: autosave with grouped versions, publishing, and a way back to any version

Pressing Save is a thing to forget, and forgetting it is how an
afternoon's work goes missing. The editor now saves for you. Saving on
every keystroke would be no better than no history at all - nobody finds
"just before I broke it" among four hundred versions of a name being
typed - so edits are grouped: a save lands two seconds after you stop,
and an unbroken stretch of editing still saves every thirty seconds.
One burst of editing becomes one version.

Publishing decides which version triggers run, and the button says which
of the two you are looking at. Version history lists every version with
the published one marked, and any of them can be put back on the canvas.

Restoring is an ordinary edit: the old graph goes on the canvas, autosave
stores it as a new version like anything else, the version it came from
stays in the list, and what runs does not move until somebody publishes.
Writing straight over the current version would make "let me see what
this looked like" destructive.

Two things had to change so that a version means something:

- A save that changes nothing now writes nothing. Every write stores a
  version, and with autosave running behind the user, a no-op save would
  fill the history with entries nobody made and bump the version out from
  under anything comparing against it.
- Publishing what is already published does nothing and says so, instead
  of storing a version whose only difference is the act of publishing it.

That second one was the cause of a real bug: publishing writes
publishedVersion onto the record, which bumps the version, so comparing
version numbers left the editor permanently one behind and always
offering to publish something nobody had changed. "Is there anything
unpublished?" is now answered by comparing the two graphs - name, nodes,
connections and settings - and ignoring the metadata, which is the only
comparison the act of publishing cannot disturb.

Verified in the browser: three edits in quick succession produce no save
during the burst and exactly one after it settles; the publish button
reads Published and is disabled until something changes, then offers to
publish and settles back; three identical saves and a second publish over
the API leave the version untouched; and restoring an old version puts it
on the canvas while the published version goes on running.
fszontagh 1 lună în urmă
părinte
comite
b88f54a2bb

+ 77 - 1
src/webserver/api/workflow_controller.cpp

@@ -27,6 +27,23 @@ WorkflowController::WorkflowController(storage::StorageClient& storage,
     , db_watcher_(db_watcher)
     , node_store_(node_store) {}
 
+
+namespace {
+
+// The parts of a workflow that decide what it does. Metadata - the version,
+// who touched it last, when it was published - deliberately left out, so
+// "did anything actually change?" is not answered yes by the act of asking.
+nlohmann::json workflowShape(const nlohmann::json& w) {
+    return nlohmann::json{
+        {"name", w.value("name", "")},
+        {"nodes", w.value("nodes", nlohmann::json::array())},
+        {"connections", w.value("connections", nlohmann::json::array())},
+        {"settings", w.value("settings", nlohmann::json::object())},
+    };
+}
+
+} // namespace
+
 void WorkflowController::materializeNodeConfigDefaults(nlohmann::json& body) {
     if (!body.contains("nodes") || !body["nodes"].is_array()) {
         return;
@@ -230,7 +247,30 @@ void WorkflowController::getWorkflow(const httplib::Request& req, httplib::Respo
         return;
     }
 
-    sendJson(res, result.value());
+    auto workflow = result.value();
+
+    // Whether the workflow that runs differs from the workflow on screen.
+    //
+    // This compares the two graphs rather than their version numbers, because
+    // publishing is itself a write: it stores publishedVersion on the record and
+    // so bumps the version, which would leave the editor permanently one behind
+    // and always offering to publish something nobody had changed.
+    const int64_t published = workflow.value("publishedVersion", int64_t{0});
+    bool unpublished = true;
+    if (published > 0) {
+        auto live = storage_.getVersion("workflows", id, published);
+        if (live.ok()) {
+            unpublished = workflowShape(workflow) != workflowShape(live.value());
+        } else {
+            // The published version has aged out of history. What runs cannot be
+            // compared with what is here, and saying "up to date" would be a
+            // guess in the direction that hides the problem.
+            unpublished = true;
+        }
+    }
+    workflow["hasUnpublishedChanges"] = unpublished;
+
+    sendJson(res, workflow);
 }
 
 void WorkflowController::createWorkflow(const httplib::Request& req, httplib::Response& res,
@@ -361,6 +401,24 @@ void WorkflowController::updateWorkflow(const httplib::Request& req, httplib::Re
 
         materializeNodeConfigDefaults(body);
 
+        // A save that changes nothing must not write anything. Every write
+        // stores a version, and with autosave running behind the user that
+        // would fill the history with entries nobody made - and bump the
+        // version out from under anyone comparing against it.
+        if (before.ok()) {
+            bool identical = true;
+            for (const auto& [key, value] : body.items()) {
+                if (!before.value().contains(key) || before.value()[key] != value) {
+                    identical = false;
+                    break;
+                }
+            }
+            if (identical) {
+                sendJson(res, before.value());
+                return;
+            }
+        }
+
         auto result = storage_.update("workflows", id, body, 0, true);
         if (result.failed()) {
             sendError(res, result.error().message(), 404);
@@ -592,6 +650,24 @@ void WorkflowController::publishWorkflow(const httplib::Request& req, httplib::R
         return;
     }
 
+    // Publishing what is already published would store a new version whose only
+    // difference is the act of publishing it, and leave the button offering to
+    // do it again. Nothing to do is not an error - say so and change nothing.
+    const int64_t already = current.value().value("publishedVersion", int64_t{0});
+    if (already > 0) {
+        auto live = storage_.getVersion("workflows", id, already);
+        if (live.ok() && workflowShape(current.value()) == workflowShape(live.value())) {
+            sendJson(res, {
+                {"id", id},
+                {"publishedVersion", already},
+                {"publishedAt", current.value().value("publishedAt", int64_t{0})},
+                {"publishedBy", current.value().value("publishedBy", std::string())},
+                {"unchanged", true},
+            });
+            return;
+        }
+    }
+
     const int64_t version = current.value().value("_version", int64_t{0});
     if (version <= 0) {
         // Without a version there is nothing to point at, and publishing would

+ 33 - 0
webui/src/api/workflows.ts

@@ -35,6 +35,9 @@ export interface Workflow {
   // one it loaded back on save so the server can refuse to overwrite somebody
   // else's work rather than silently discarding it.
   version?: number
+  /** True when what runs differs from what is stored - see getWorkflow. */
+  hasUnpublishedChanges?: boolean
+  publishedVersion?: number
 }
 
 export interface NodeOutput {
@@ -91,6 +94,8 @@ function transformWorkflow(data: any): Workflow {
     createdBy: data._created_by || undefined,
     updatedBy: data._updated_by || undefined,
     version: typeof data._version === 'number' ? data._version : undefined,
+    hasUnpublishedChanges: data.hasUnpublishedChanges,
+    publishedVersion: typeof data.publishedVersion === 'number' ? data.publishedVersion : undefined,
   }
 }
 
@@ -138,6 +143,34 @@ export const workflowsApi = {
    *
    * The copy lands in the same folder as the original unless told otherwise.
    */
+  /** Version history: newest first. */
+  versions: async (id: string, limit = 50) => {
+    const response = await api.get(`/workflows/${id}/versions`, { params: { limit } })
+    return response.data as {
+      versions: { version: number; createdAt: number; isPublished: boolean; isCurrent: boolean }[]
+      totalCount: number
+      hasMore: boolean
+      currentVersion: number
+      publishedVersion: number
+    }
+  },
+
+  version: async (id: string, version: number) => {
+    const response = await api.get(`/workflows/${id}/versions/${version}`)
+    return transformWorkflow(response.data)
+  },
+
+  /** Makes the version currently stored the one that triggers run. */
+  publish: async (id: string) => {
+    const response = await api.post(`/workflows/${id}/publish`, {})
+    return response.data as {
+      id: string
+      publishedVersion: number
+      publishedAt: number
+      publishedBy: string
+    }
+  },
+
   duplicate: async (id: string, options?: { name?: string; groupId?: string | null }) => {
     // Read the workflow fresh rather than copying whatever a list page happens
     // to be holding, so an edit made in another tab is not silently reverted

+ 42 - 1
webui/src/components/workflow/EditorHeader.tsx

@@ -1,4 +1,4 @@
-import { ArrowLeft, Save, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil, Copy, Undo2, Redo2} from 'lucide-react'
+import { ArrowLeft, Save, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil, Copy, Undo2, Redo2, UploadCloud } from 'lucide-react'
 import { useState, useRef, useEffect, useCallback } from 'react'
 import type { ExecutionDetail } from '../../api/workflows'
 
@@ -30,6 +30,11 @@ interface EditorHeaderProps {
   onAutoLayout: () => void
   otherViewers?: { userId: string; username: string }[]
   myOtherTabs?: number
+  onPublish: () => void
+  isPublishing: boolean
+  publishedVersion: number | null
+  hasUnpublishedChanges: boolean
+  onShowVersions: () => void
   onUndo: () => void
   onRedo: () => void
   canUndo: boolean
@@ -65,6 +70,11 @@ export function EditorHeader({
   onAutoLayout,
   otherViewers = [],
   myOtherTabs = 0,
+  onPublish,
+  isPublishing,
+  publishedVersion,
+  hasUnpublishedChanges,
+  onShowVersions,
   onUndo,
   onRedo,
   canUndo,
@@ -237,6 +247,37 @@ export function EditorHeader({
         >
           <Redo2 className="w-4 h-4" />
         </button>
+        {/* Publishing is what decides which version triggers run, so it says
+            plainly whether the stored workflow has moved past it. */}
+        <button
+          onClick={onShowVersions}
+          className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
+          title="Version history"
+          disabled={isViewingExecution}
+        >
+          <History className="w-4 h-4" />
+        </button>
+        <button
+          onClick={onPublish}
+          disabled={isViewingExecution || isPublishing || !hasUnpublishedChanges || hasChanges}
+          title={
+            hasChanges
+              ? 'Waiting for the current change to save - publishing sends what is stored, not what is on screen'
+              : !hasUnpublishedChanges
+                ? `Version ${publishedVersion} is published, and is exactly what triggers run`
+                : publishedVersion === null
+                  ? 'Nothing published yet - publish to decide what triggers run'
+                  : `Publish what is stored now. Triggers currently run version ${publishedVersion}`
+          }
+          className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${
+            hasUnpublishedChanges && !hasChanges
+              ? 'bg-green-600 text-white hover:bg-green-700'
+              : 'text-gray-500 dark:text-gray-400'
+          } disabled:opacity-50`}
+        >
+          <UploadCloud className="w-4 h-4" />
+          {isPublishing ? 'Publishing...' : hasUnpublishedChanges ? 'Publish' : 'Published'}
+        </button>
         <button
           onClick={onAutoLayout}
           className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"

+ 144 - 0
webui/src/components/workflow/VersionHistoryPanel.tsx

@@ -0,0 +1,144 @@
+import { useEffect, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { X, Check, RotateCcw } from 'lucide-react'
+import { workflowsApi, Workflow } from '../../api/workflows'
+import { format } from 'date-fns'
+
+/**
+ * Every version of the workflow, and a way back to one of them.
+ *
+ * Restoring puts the old graph on the canvas as an ordinary edit rather than
+ * writing it anywhere directly - autosave then stores it as a new version like
+ * any other change. Nothing is overwritten: the version it came from is still
+ * in the list, and what triggers run does not move until somebody publishes.
+ */
+
+interface Props {
+  workflowId: string
+  publishedVersion: number | null
+  onClose: () => void
+  onRestore: (workflow: Workflow) => void
+}
+
+export function VersionHistoryPanel({ workflowId, publishedVersion, onClose, onRestore }: Props) {
+  const [restoring, setRestoring] = useState<number | null>(null)
+  const [error, setError] = useState<string | null>(null)
+
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose()
+    }
+    window.addEventListener('keydown', onKey)
+    return () => window.removeEventListener('keydown', onKey)
+  }, [onClose])
+
+  const { data, isLoading } = useQuery({
+    queryKey: ['workflow-versions', workflowId],
+    queryFn: () => workflowsApi.versions(workflowId),
+  })
+
+  const when = (ts: number) => {
+    // The database writes these in nanoseconds; everything above it uses
+    // milliseconds. A raw value here would read as a date in the year 58000.
+    const ms = ts > 1e14 ? Math.floor(ts / 1e6) : ts
+    try {
+      return format(new Date(ms), 'MMM d, HH:mm:ss')
+    } catch {
+      return 'unknown'
+    }
+  }
+
+  const restore = async (version: number) => {
+    setRestoring(version)
+    setError(null)
+    try {
+      const workflow = await workflowsApi.version(workflowId, version)
+      onRestore(workflow)
+    } catch (e: any) {
+      setError(e?.response?.data?.error || 'That version could not be read back')
+    } finally {
+      setRestoring(null)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
+      <div
+        className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md max-h-[90vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
+          <h3 className="font-semibold text-gray-900 dark:text-gray-100">Version history</h3>
+          <button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded">
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-4 overflow-y-auto text-sm">
+          {isLoading && <p className="text-gray-500 dark:text-gray-400">Reading the history...</p>}
+
+          {!isLoading && !data?.versions?.length && (
+            <p className="text-gray-500 dark:text-gray-400">
+              Nothing here yet. A version is written each time the workflow saves.
+            </p>
+          )}
+
+          {error && <p className="text-red-600 dark:text-red-400 mb-2">{error}</p>}
+
+          <ul className="space-y-1">
+            {data?.versions?.map((v) => {
+              const isPublished = v.version === publishedVersion || v.isPublished
+              return (
+                <li
+                  key={v.version}
+                  className="flex items-center gap-3 px-3 py-2 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50"
+                >
+                  <span className="font-mono text-xs text-gray-500 dark:text-gray-400 w-10">
+                    v{v.version}
+                  </span>
+                  <span className="flex-1 text-gray-900 dark:text-gray-100">{when(v.createdAt)}</span>
+
+                  {isPublished && (
+                    <span
+                      className="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300"
+                      title="Triggers run this version"
+                    >
+                      <Check className="w-3 h-3" /> live
+                    </span>
+                  )}
+                  {v.isCurrent && (
+                    <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300">
+                      current
+                    </span>
+                  )}
+
+                  <button
+                    onClick={() => restore(v.version)}
+                    disabled={restoring !== null}
+                    className="flex items-center gap-1 px-2 py-1 rounded text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 disabled:opacity-40"
+                    title="Put this version on the canvas"
+                  >
+                    <RotateCcw className="w-3.5 h-3.5" />
+                    {restoring === v.version ? '...' : 'Restore'}
+                  </button>
+                </li>
+              )
+            })}
+          </ul>
+
+          {data?.hasMore && (
+            <p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
+              Showing the {data.versions.length} most recent of {data.totalCount}.
+            </p>
+          )}
+        </div>
+
+        <div className="px-5 py-3 border-t border-gray-200 dark:border-slate-700 text-xs text-gray-500 dark:text-gray-400">
+          Restoring puts that version on the canvas, and autosave stores it as a new
+          version shortly after - the one you restored from stays in this list either
+          way. What triggers run does not change until you publish.
+        </div>
+      </div>
+    </div>
+  )
+}

+ 66 - 0
webui/src/hooks/useAutosave.ts

@@ -0,0 +1,66 @@
+import { useEffect, useRef } from 'react'
+
+/**
+ * Saves the workflow for you, and decides how often that is worth doing.
+ *
+ * Pressing Save is a thing to forget, and forgetting it is how an afternoon's
+ * work goes missing. But saving on every keystroke would fill the version
+ * history with one entry per character typed, which is the same as having no
+ * history at all - nobody can find "just before I broke it" in four hundred
+ * versions of a name being typed.
+ *
+ * So edits are grouped: a save lands once you stop for a moment, and a
+ * continuous stream still saves periodically rather than never. One burst of
+ * editing becomes one version.
+ */
+
+// Long enough that typing a node name, dragging a node into place or working
+// through a config form lands as one version rather than a dozen.
+const IDLE_MS = 2000
+
+// Somebody who never quite stops - dragging things around for minutes - still
+// gets their work written down.
+const MAX_WAIT_MS = 30000
+
+/**
+ * @param editToken something whose identity changes on every edit - the node
+ *   and edge arrays, typically. `hasChanges` alone cannot drive this: it is
+ *   already true by the second keystroke and stays true, so an effect watching
+ *   it would never re-run and the wait would never restart.
+ */
+export function useAutosave(
+  editToken: unknown,
+  hasChanges: boolean,
+  enabled: boolean,
+  save: () => void
+) {
+  const saveRef = useRef(save)
+  saveRef.current = save
+
+  // When the current run of unsaved changes began, so a long unbroken stretch
+  // of editing is not left unsaved for its whole duration.
+  const dirtySinceRef = useRef<number | null>(null)
+
+  useEffect(() => {
+    if (!enabled || !hasChanges) {
+      dirtySinceRef.current = null
+      return
+    }
+
+    if (dirtySinceRef.current === null) {
+      dirtySinceRef.current = Date.now()
+    }
+
+    const waited = Date.now() - dirtySinceRef.current
+    const delay = Math.max(0, Math.min(IDLE_MS, MAX_WAIT_MS - waited))
+
+    const timer = setTimeout(() => {
+      dirtySinceRef.current = null
+      saveRef.current()
+    }, delay)
+
+    // Each new edit cancels the pending save and starts the wait again, which
+    // is what collapses a burst into one version.
+    return () => clearTimeout(timer)
+  }, [editToken, hasChanges, enabled])
+}

+ 60 - 0
webui/src/pages/WorkflowEditorPage.tsx

@@ -32,11 +32,13 @@ import { AlertTriangle } from 'lucide-react'
 import { wsClient } from '../api/client'
 import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
 import { useWorkflowLocks } from '../hooks/useWorkflowLocks'
+import { useAutosave } from '../hooks/useAutosave'
 import { diffWorkflows, graphDelta, mergeWorkflows, summariseChanges, type Change } from '../utils/workflowDiff'
 import type { AvailableField } from '../components/ConditionBuilder'
 import type { FieldInfo } from '../components/AvailableDataPanel'
 import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
 import { ExecutionViewerBanner } from '../components/workflow/ExecutionViewerBanner'
+import { VersionHistoryPanel } from '../components/workflow/VersionHistoryPanel'
 import { useExecutionPinStore } from '../stores/executionPinStore'
 import type { ExecutionDetail, WorkflowNode as WorkflowNodeType, Connection as WorkflowConnection } from '../api/workflows'
 
@@ -587,6 +589,14 @@ function WorkflowEditorInner() {
   // 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 [publishedVersion, setPublishedVersion] = useState<number | null>(null)
+  // Answered by the server by comparing the graphs, not the version numbers -
+  // publishing is itself a write, so a number comparison is always one behind.
+  const [hasUnpublishedChanges, setHasUnpublishedChanges] = useState(false)
+  const [showVersions, setShowVersions] = useState(false)
+  // Autosave is set up before handleSave is defined, and calling it through a
+  // ref avoids reordering several hundred lines to satisfy the compiler.
+  const handleSaveRef = useRef<() => void>(() => {})
 
   const loadedVersionRef = useRef<number | null>(null)
   const { others: otherViewers, myOtherTabs, savedByOther, dismissSavedByOther } =
@@ -610,6 +620,9 @@ function WorkflowEditorInner() {
   useEffect(() => {
     if (!workflow) return
     if (typeof workflow.version === 'number') loadedVersionRef.current = workflow.version
+    const published = workflow.publishedVersion
+    setPublishedVersion(typeof published === 'number' && published > 0 ? published : null)
+    setHasUnpublishedChanges(workflow.hasUnpublishedChanges !== false)
     if (baselineVersionRef.current === (workflow.version ?? null)) return
     baselineVersionRef.current = workflow.version ?? null
     baselineRef.current = {
@@ -880,6 +893,26 @@ function WorkflowEditorInner() {
     },
   })
 
+  const publishMutation = useMutation({
+    mutationFn: () => workflowsApi.publish(id!),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['workflow', id] })
+      queryClient.invalidateQueries({ queryKey: ['workflow-versions', id] })
+      setPublishedVersion(result.publishedVersion)
+      setHasUnpublishedChanges(false)
+      showToast('success', `Published version ${result.publishedVersion}. Triggers will run this from now on.`)
+    },
+    onError: (error: any) => {
+      showToast('error', error?.response?.data?.error || `Could not publish: ${error.message}`)
+    },
+  })
+
+  // Autosave, so nobody has to remember to press anything. Edits are grouped -
+  // see the hook for why one version per keystroke would be no history at all.
+  useAutosave(nodes, hasChanges, !isViewingExecution && !!id && !saveMutation.isPending, () => {
+    handleSaveRef.current()
+  })
+
   // Duplicating copies what is STORED, so unsaved edits on the canvas would not
   // make it into the copy. Rather than quietly producing a copy of an older
   // version, refuse and say so - a silently stale duplicate is very hard to
@@ -1720,6 +1753,7 @@ function WorkflowEditorInner() {
       expectedVersion: loadedVersionRef.current ?? undefined,
     } as any)
   }, [currentGraph, saveMutation])
+  handleSaveRef.current = handleSave
 
   // Handle workflow rename
   const handleRename = useCallback((newName: string) => {
@@ -3037,6 +3071,27 @@ function WorkflowEditorInner() {
         </div>
       )}
 
+      {showVersions && id && (
+        <VersionHistoryPanel
+          workflowId={id}
+          publishedVersion={publishedVersion}
+          onClose={() => setShowVersions(false)}
+          onRestore={(restored) => {
+            // Restoring puts the old graph on the canvas as unsaved work rather
+            // than writing it straight to the record. It is somebody's edit
+            // like any other - they can look at it, change their mind, or save
+            // it, and the version they came from is still there either way.
+            setNodes(restored.nodes.map((n: any) =>
+              makeNodeFromStored(n, nodeDefsMap, () => executeWorkflow(), executeTrigger, executionState)))
+            setEdges(buildEdgesFromStored(restored.connections))
+            if (restored.name) setWorkflowName(restored.name)
+            setHasChanges(true)
+            setShowVersions(false)
+            showToast('success', 'Put that version on the canvas. It saves as a new version - what runs does not change until you publish.')
+          }}
+        />
+      )}
+
       {/* Execution Viewer Banner */}
       {isViewingExecution && pinnedExecution && (
         <ExecutionViewerBanner
@@ -3073,6 +3128,11 @@ function WorkflowEditorInner() {
         onExecuteTrigger={executeTrigger}
         onAddNode={() => setShowNodePicker(true)}
         onAutoLayout={autoLayout}
+        onPublish={() => publishMutation.mutate()}
+        isPublishing={publishMutation.isPending}
+        publishedVersion={publishedVersion}
+        hasUnpublishedChanges={hasUnpublishedChanges}
+        onShowVersions={() => setShowVersions(true)}
         otherViewers={otherViewers}
         myOtherTabs={myOtherTabs}
         onUndo={() => { if (history.undo()) setHasChanges(true) }}