@tita-agent/dsh-data-table 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -2
  3. package/cordis.patch.yml +7 -0
  4. package/lib/client.js +16766 -0
  5. package/lib/contract-envelope.d.ts +91 -0
  6. package/lib/contract-envelope.js +106 -0
  7. package/lib/contract-layout.d.ts +130 -0
  8. package/lib/contract-layout.js +138 -0
  9. package/lib/contract-refs.d.ts +39 -0
  10. package/lib/contract-refs.js +9 -0
  11. package/lib/contract-version.d.ts +311 -0
  12. package/lib/contract-version.js +270 -0
  13. package/lib/contract-wire.d.ts +147 -0
  14. package/lib/contract-wire.js +99 -0
  15. package/lib/contracts.d.ts +12 -0
  16. package/lib/contracts.js +12 -0
  17. package/lib/types/audit.d.ts +42 -0
  18. package/lib/types/audit.js +39 -0
  19. package/lib/types/client/ProjectImportMenu.d.ts +30 -0
  20. package/lib/types/client/ProjectImportMenu.js +70 -0
  21. package/lib/types/client/SheetPanel.d.ts +33 -0
  22. package/lib/types/client/SheetPanel.js +1766 -0
  23. package/lib/types/client/SheetToggle.d.ts +18 -0
  24. package/lib/types/client/SheetToggle.js +26 -0
  25. package/lib/types/client/index.d.ts +15 -0
  26. package/lib/types/client/index.js +110 -0
  27. package/lib/types/client/persistence/changes.d.ts +15 -0
  28. package/lib/types/client/persistence/changes.js +51 -0
  29. package/lib/types/client/persistence/ids.d.ts +34 -0
  30. package/lib/types/client/persistence/ids.js +48 -0
  31. package/lib/types/client/persistence/merge.d.ts +35 -0
  32. package/lib/types/client/persistence/merge.js +362 -0
  33. package/lib/types/client/store.d.ts +61 -0
  34. package/lib/types/client/store.js +152 -0
  35. package/lib/types/client/surface-context.d.ts +27 -0
  36. package/lib/types/client/surface-context.js +104 -0
  37. package/lib/types/client/surface-control.d.ts +2 -0
  38. package/lib/types/client/surface-control.js +12 -0
  39. package/lib/types/client/transport/workbookApi.d.ts +159 -0
  40. package/lib/types/client/transport/workbookApi.js +246 -0
  41. package/lib/types/client/univer/StandaloneWorkbookPage.d.ts +5 -0
  42. package/lib/types/client/univer/StandaloneWorkbookPage.js +7 -0
  43. package/lib/types/client/univer/WorkbookCanvas.d.ts +40 -0
  44. package/lib/types/client/univer/WorkbookCanvas.js +87 -0
  45. package/lib/types/client/univer/WorkbookView.d.ts +3 -0
  46. package/lib/types/client/univer/WorkbookView.js +2 -0
  47. package/lib/types/client/univer/loader.d.ts +18 -0
  48. package/lib/types/client/univer/loader.js +81 -0
  49. package/lib/types/client/univer/standalone-entry.d.ts +1 -0
  50. package/lib/types/client/univer/standalone-entry.js +19 -0
  51. package/lib/types/client/versions/VersionChrome.d.ts +33 -0
  52. package/lib/types/client/versions/VersionChrome.js +21 -0
  53. package/lib/types/client/versions/inspect.d.ts +70 -0
  54. package/lib/types/client/versions/inspect.js +88 -0
  55. package/lib/types/client/versions/preview-decoration.d.ts +11 -0
  56. package/lib/types/client/versions/preview-decoration.js +54 -0
  57. package/lib/types/client/versions/records.d.ts +19 -0
  58. package/lib/types/client/versions/records.js +70 -0
  59. package/lib/types/client/workspace-host.d.ts +61 -0
  60. package/lib/types/client/workspace-host.js +124 -0
  61. package/lib/types/collaboration-hub.d.ts +31 -0
  62. package/lib/types/collaboration-hub.js +69 -0
  63. package/lib/types/contract/ids.d.ts +24 -0
  64. package/lib/types/contract/ids.js +46 -0
  65. package/lib/types/contract/mutations.d.ts +25 -0
  66. package/lib/types/contract/mutations.js +782 -0
  67. package/lib/types/contract/operations.d.ts +54 -0
  68. package/lib/types/contract/operations.js +203 -0
  69. package/lib/types/contract/snapshots.d.ts +176 -0
  70. package/lib/types/contract/snapshots.js +351 -0
  71. package/lib/types/contract/univer-api-catalog.d.ts +29 -0
  72. package/lib/types/contract/univer-api-catalog.js +264 -0
  73. package/lib/types/errors.d.ts +8 -0
  74. package/lib/types/errors.js +12 -0
  75. package/lib/types/http/assets-routes.d.ts +3 -0
  76. package/lib/types/http/assets-routes.js +169 -0
  77. package/lib/types/http/collaboration.d.ts +10 -0
  78. package/lib/types/http/collaboration.js +154 -0
  79. package/lib/types/http/history.d.ts +15 -0
  80. package/lib/types/http/history.js +134 -0
  81. package/lib/types/http/project-files.d.ts +79 -0
  82. package/lib/types/http/project-files.js +206 -0
  83. package/lib/types/http/project-scope.d.ts +50 -0
  84. package/lib/types/http/project-scope.js +52 -0
  85. package/lib/types/http/routes.d.ts +98 -0
  86. package/lib/types/http/routes.js +323 -0
  87. package/lib/types/http/standalone.d.ts +9 -0
  88. package/lib/types/http/standalone.js +31 -0
  89. package/lib/types/http/trunk.d.ts +10 -0
  90. package/lib/types/http/trunk.js +322 -0
  91. package/lib/types/http/worktree.d.ts +9 -0
  92. package/lib/types/http/worktree.js +145 -0
  93. package/lib/types/index.d.ts +18 -0
  94. package/lib/types/index.js +156 -0
  95. package/lib/types/invariant.d.ts +15 -0
  96. package/lib/types/invariant.js +15 -0
  97. package/lib/types/request-params.d.ts +21 -0
  98. package/lib/types/request-params.js +76 -0
  99. package/lib/types/store/paths.d.ts +68 -0
  100. package/lib/types/store/paths.js +148 -0
  101. package/lib/types/store/project.d.ts +88 -0
  102. package/lib/types/store/project.js +120 -0
  103. package/lib/types/store/read.d.ts +110 -0
  104. package/lib/types/store/read.js +404 -0
  105. package/lib/types/store/retention.d.ts +27 -0
  106. package/lib/types/store/retention.js +111 -0
  107. package/lib/types/store/write.d.ts +22 -0
  108. package/lib/types/store/write.js +210 -0
  109. package/lib/types/tools/arguments.d.ts +40 -0
  110. package/lib/types/tools/arguments.js +130 -0
  111. package/lib/types/tools/cells.d.ts +50 -0
  112. package/lib/types/tools/cells.js +298 -0
  113. package/lib/types/tools/index.d.ts +2 -0
  114. package/lib/types/tools/index.js +250 -0
  115. package/lib/types/tools/render.d.ts +47 -0
  116. package/lib/types/tools/render.js +290 -0
  117. package/lib/types/tools/univer-api.d.ts +18 -0
  118. package/lib/types/tools/univer-api.js +198 -0
  119. package/lib/types/tools/workbook-catalog.d.ts +3 -0
  120. package/lib/types/tools/workbook-catalog.js +83 -0
  121. package/lib/types/tools/workbook-edit.d.ts +192 -0
  122. package/lib/types/tools/workbook-edit.js +912 -0
  123. package/lib/types/tools/workbook-find.d.ts +11 -0
  124. package/lib/types/tools/workbook-find.js +89 -0
  125. package/lib/types/tools/workbook-history.d.ts +2 -0
  126. package/lib/types/tools/workbook-history.js +59 -0
  127. package/lib/types/tools/workbook-read.d.ts +21 -0
  128. package/lib/types/tools/workbook-read.js +204 -0
  129. package/lib/types/types.d.ts +377 -0
  130. package/lib/types/types.js +34 -0
  131. package/lib/types/univer/apply-operation.d.ts +14 -0
  132. package/lib/types/univer/apply-operation.js +398 -0
  133. package/lib/types/univer/chunk.d.ts +68 -0
  134. package/lib/types/univer/chunk.js +16 -0
  135. package/lib/types/univer/decoration.d.ts +34 -0
  136. package/lib/types/univer/decoration.js +88 -0
  137. package/lib/types/univer/diagnostics.d.ts +31 -0
  138. package/lib/types/univer/diagnostics.js +141 -0
  139. package/lib/types/univer/engine.d.ts +33 -0
  140. package/lib/types/univer/engine.js +338 -0
  141. package/lib/types/univer/entry.d.ts +8 -0
  142. package/lib/types/univer/entry.js +11 -0
  143. package/lib/types/univer/facade.d.ts +107 -0
  144. package/lib/types/univer/facade.js +1 -0
  145. package/lib/types/univer/fonts.d.ts +202 -0
  146. package/lib/types/univer/fonts.js +140 -0
  147. package/lib/types/univer/plugins/collaboration.d.ts +23 -0
  148. package/lib/types/univer/plugins/collaboration.js +47 -0
  149. package/lib/types/univer/plugins/export.d.ts +39 -0
  150. package/lib/types/univer/plugins/export.js +246 -0
  151. package/lib/types/univer/plugins/history.d.ts +24 -0
  152. package/lib/types/univer/plugins/history.js +50 -0
  153. package/lib/types/univer/plugins/version.d.ts +13 -0
  154. package/lib/types/univer/plugins/version.js +13 -0
  155. package/lib/types/version/diff.d.ts +36 -0
  156. package/lib/types/version/diff.js +156 -0
  157. package/lib/types/version/grouping.d.ts +7 -0
  158. package/lib/types/version/grouping.js +94 -0
  159. package/lib/types/version/records.d.ts +17 -0
  160. package/lib/types/version/records.js +102 -0
  161. package/lib/types/version/session.d.ts +36 -0
  162. package/lib/types/version/session.js +89 -0
  163. package/lib/types/version/summary.d.ts +24 -0
  164. package/lib/types/version/summary.js +111 -0
  165. package/lib/types/workbook-manager.d.ts +191 -0
  166. package/lib/types/workbook-manager.js +935 -0
  167. package/lib/types/xlsx-export-worker.d.ts +5 -0
  168. package/lib/types/xlsx-export-worker.js +24 -0
  169. package/lib/types/xlsx-export.d.ts +21 -0
  170. package/lib/types/xlsx-export.js +371 -0
  171. package/lib/types/xlsx-import-worker.d.ts +1 -0
  172. package/lib/types/xlsx-import-worker.js +19 -0
  173. package/lib/univer-client.js +690 -0
  174. package/lib/workbook-page.js +23826 -0
  175. package/lib/xlsx-export-worker.js +104 -0
  176. package/lib/xlsx-import-worker.js +46 -0
  177. package/package.json +106 -3
  178. package/src/audit.ts +58 -0
  179. package/src/client/ProjectImportMenu.tsx +100 -0
  180. package/src/client/SheetPanel.module.css +1578 -0
  181. package/src/client/SheetPanel.tsx +1934 -0
  182. package/src/client/SheetToggle.module.css +73 -0
  183. package/src/client/SheetToggle.tsx +65 -0
  184. package/src/client/contract.d.ts +19 -0
  185. package/src/client/index.ts +131 -0
  186. package/src/client/persistence/changes.ts +58 -0
  187. package/src/client/persistence/ids.ts +60 -0
  188. package/src/client/persistence/merge.ts +392 -0
  189. package/src/client/store.ts +188 -0
  190. package/src/client/surface-context.ts +123 -0
  191. package/src/client/surface-control.ts +13 -0
  192. package/src/client/transport/workbookApi.ts +355 -0
  193. package/src/client/univer/SheetView.module.css +110 -0
  194. package/src/client/univer/StandaloneWorkbookPage.module.css +8 -0
  195. package/src/client/univer/StandaloneWorkbookPage.tsx +15 -0
  196. package/src/client/univer/WorkbookCanvas.tsx +128 -0
  197. package/src/client/univer/WorkbookView.module.css +30 -0
  198. package/src/client/univer/WorkbookView.tsx +3 -0
  199. package/src/client/univer/loader.ts +90 -0
  200. package/src/client/univer/react-dom-client.d.ts +10 -0
  201. package/src/client/univer/standalone-entry.tsx +19 -0
  202. package/src/client/univer/standalone.css +12 -0
  203. package/src/client/univer/univer-locales.d.ts +89 -0
  204. package/src/client/univer/univer-modules.d.ts +26 -0
  205. package/src/client/versions/VersionChrome.tsx +114 -0
  206. package/src/client/versions/inspect.ts +138 -0
  207. package/src/client/versions/preview-decoration.ts +54 -0
  208. package/src/client/versions/records.ts +78 -0
  209. package/src/client/workspace-host.ts +147 -0
  210. package/src/collaboration-hub.ts +83 -0
  211. package/src/contract/ids.ts +50 -0
  212. package/src/contract/mutations.ts +791 -0
  213. package/src/contract/operations.ts +221 -0
  214. package/src/contract/snapshots.ts +383 -0
  215. package/src/contract/univer-api-catalog.ts +289 -0
  216. package/src/css-modules.d.ts +6 -0
  217. package/src/errors.ts +25 -0
  218. package/src/http/assets-routes.ts +177 -0
  219. package/src/http/collaboration.ts +144 -0
  220. package/src/http/history.ts +151 -0
  221. package/src/http/project-files.ts +227 -0
  222. package/src/http/project-scope.ts +77 -0
  223. package/src/http/routes.ts +364 -0
  224. package/src/http/standalone.ts +32 -0
  225. package/src/http/trunk.ts +322 -0
  226. package/src/http/worktree.ts +119 -0
  227. package/src/index.ts +165 -0
  228. package/src/invariant.ts +17 -0
  229. package/src/request-params.ts +92 -0
  230. package/src/store/paths.ts +165 -0
  231. package/src/store/project.ts +137 -0
  232. package/src/store/read.ts +454 -0
  233. package/src/store/retention.ts +106 -0
  234. package/src/store/write.ts +230 -0
  235. package/src/tools/arguments.ts +128 -0
  236. package/src/tools/cells.ts +298 -0
  237. package/src/tools/index.ts +274 -0
  238. package/src/tools/render.ts +301 -0
  239. package/src/tools/univer-api.ts +243 -0
  240. package/src/tools/workbook-catalog.ts +94 -0
  241. package/src/tools/workbook-edit.ts +987 -0
  242. package/src/tools/workbook-find.ts +80 -0
  243. package/src/tools/workbook-history.ts +63 -0
  244. package/src/tools/workbook-read.ts +210 -0
  245. package/src/types.ts +432 -0
  246. package/src/univer/apply-operation.ts +369 -0
  247. package/src/univer/chunk.ts +77 -0
  248. package/src/univer/decoration.ts +111 -0
  249. package/src/univer/diagnostics.ts +136 -0
  250. package/src/univer/engine.ts +370 -0
  251. package/src/univer/entry.ts +14 -0
  252. package/src/univer/facade.ts +91 -0
  253. package/src/univer/fonts.ts +148 -0
  254. package/src/univer/overrides.css +33 -0
  255. package/src/univer/plugins/collaboration.ts +47 -0
  256. package/src/univer/plugins/export.ts +236 -0
  257. package/src/univer/plugins/history.ts +49 -0
  258. package/src/univer/plugins/version.ts +13 -0
  259. package/src/version/diff.ts +169 -0
  260. package/src/version/grouping.ts +96 -0
  261. package/src/version/records.ts +113 -0
  262. package/src/version/session.ts +90 -0
  263. package/src/version/summary.ts +117 -0
  264. package/src/workbook-manager.ts +1111 -0
  265. package/src/xlsx-export-worker.ts +34 -0
  266. package/src/xlsx-export.ts +373 -0
  267. package/src/xlsx-import-worker.ts +30 -0
@@ -0,0 +1,1934 @@
1
+ /**
2
+ * Right-column workbook canvas. Univer owns the editing experience; this
3
+ * component owns the browser projection of the DSH workbook service:
4
+ * snapshot bootstrap, incremental user operations, and Agent-operation sync.
5
+ * @module dsh-data-table/client/SheetPanel
6
+ */
7
+
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
+ import type { SurfaceBridgeService } from '@tita-agent/dsh-surface-bridge'
11
+ import { Button, Input } from '@tita-agent/ui/components'
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ Drawer,
20
+ DrawerBody,
21
+ DrawerContent,
22
+ DrawerDescription,
23
+ DrawerHeader,
24
+ DrawerTitle,
25
+ useFullPage,
26
+ } from '@tita-agent/ui/overlays'
27
+ import {
28
+ ArrowDownToLine,
29
+ ExternalLink,
30
+ FileJson,
31
+ FileSpreadsheet,
32
+ FileText,
33
+ History,
34
+ Save,
35
+ X,
36
+ } from '@tita-agent/ui/icons'
37
+ import {
38
+ ObjectPicker,
39
+ SaveStatus,
40
+ ToolbarMenu,
41
+ TopBarDivider,
42
+ TopBarGroup,
43
+ WorkspaceTopBar,
44
+ } from '@tita-agent/ui/workspace'
45
+ import { ProjectImportMenu } from './ProjectImportMenu.tsx'
46
+ import {
47
+ RestoreDialog,
48
+ SaveVersionDialog,
49
+ VersionInspectBar,
50
+ VersionManager,
51
+ autoVersionName,
52
+ CURRENT_VERSION_LABEL,
53
+ restoreVersionSummary,
54
+ type VersionManagerActions,
55
+ type VersionManagerEntry,
56
+ type VersionManagerGroup,
57
+ type VersionRestoreIntent,
58
+ } from '@tita-agent/ui/versions'
59
+ import '@tita-agent/ui/styles.css'
60
+ import { VERSION_MAX_STEPS, undoTargetOf, versionTimeline } from '#contracts'
61
+ import type { WorkbookFocusRange, WorkbookLocalChange, WorkbookOperation, WorkbookSummary, WorkbookPresence, WorkbookPresencePayload, WorkbookSelectionRange, WorkbookChange } from '../types.ts'
62
+ import { cloneSnapshot } from '../contract/snapshots.ts'
63
+ import {
64
+ cachedWorkbookProjection,
65
+ forgetWorkbookProjection,
66
+ lastSelectedWorkbookId,
67
+ readVersionSession,
68
+ rememberVersionSession,
69
+ rememberWorkbook,
70
+ rememberWorkbookProjection,
71
+ useSheetOpen,
72
+ type VersionSession,
73
+ } from './store.ts'
74
+ import { WorkbookCanvas, type WorkbookCanvasHandle } from './univer/WorkbookCanvas.tsx'
75
+ import { DATA_SHEET_SURFACE_ID, dataSheetFocusTargetOf, projectDataSheetSelection } from './surface-context.ts'
76
+ import { registerDataSheetFocusHandler } from './surface-control.ts'
77
+ import { formatHistoryTime, historyVersionsFromEntries, toVersionManagerEntry } from './versions/records.ts'
78
+ import { VersionChrome } from './versions/VersionChrome.tsx'
79
+ import {
80
+ baselineRevisionOf,
81
+ inspectOptionsOf,
82
+ inspectorSummaryOf,
83
+ previewDecorationOf,
84
+ type VersionRestoreRequest,
85
+ type WorkbookPreviewState,
86
+ } from './versions/inspect.ts'
87
+ import { toDecoration } from './versions/preview-decoration.ts'
88
+ import { canApplyRemoteWhileEditing, coalesceLocalChanges, withWorkbookName } from './persistence/changes.ts'
89
+ import { createOwnOpLog, nextUserVersionId } from './persistence/ids.ts'
90
+ import {
91
+ collaborationStreamPath,
92
+ dataSheetSessionId,
93
+ standaloneWorkbookPath,
94
+ dataSheetClientPath,
95
+ createWorkbook,
96
+ deleteWorkbook as deleteWorkbookRequest,
97
+ fetchChanges,
98
+ fetchHistory,
99
+ fetchHistorySnapshot,
100
+ fetchHistoryDiff,
101
+ fetchWorkbook,
102
+ fetchWorkbooks,
103
+ HttpStatusError,
104
+ RevisionConflictError,
105
+ rollbackWorkbook,
106
+ saveOperationChanges,
107
+ saveSnapshot,
108
+ publishPresence,
109
+ renameWorkbook as renameWorkbookRequest,
110
+ type SaveResult,
111
+ } from './transport/workbookApi.ts'
112
+ import {
113
+ mergeWorkbookSnapshots,
114
+ applyIncrementalChangesToSnapshot,
115
+ applyIncrementalOperationsToSnapshot,
116
+ stripConflictMarkers,
117
+ type MergeConflict,
118
+ } from './persistence/merge.ts'
119
+ import styles from './SheetPanel.module.css'
120
+
121
+ // Safety cap only; the merge window and explicit save are the real boundaries (PRD §4.1).
122
+ /**
123
+ * How many Steps one unretained session may hold before the Host closes it and
124
+ * a new Version starts. The cap lives in the shared contract (PRD v3.3 D21) so
125
+ * the client and server gate cannot disagree about it.
126
+ */
127
+ const HUMAN_VERSION_STEP_LIMIT = VERSION_MAX_STEPS
128
+ const SYNC_INTERVAL_MS = 1500
129
+
130
+ type PanelProps = PropsRuntime<'details'>
131
+
132
+ export interface WorkbookWorkspaceProps {
133
+ /** Whether the workspace is live. The embedded pane keeps this true even while parked. */
134
+ open: boolean
135
+ /**
136
+ * Whether the workspace is on screen. Defaults to `open`.
137
+ *
138
+ * The resident workspace outlives the pane, so `open` cannot answer this; the
139
+ * few behaviours that must follow real visibility (the window-level save
140
+ * shortcut) read this instead.
141
+ */
142
+ visible?: boolean
143
+ /** Standalone mode removes the in-panel affordances that only make sense in DSH. */
144
+ standalone?: boolean
145
+ /** Optional workbook to load first; embedded mode otherwise requires explicit selection. */
146
+ initialWorkbookId?: string
147
+ /** Shared DSH composer context bridge; absent in standalone pages. */
148
+ bridge?: SurfaceBridgeService
149
+ }
150
+
151
+ type WorkbookEnvelope = import('./transport/workbookApi.ts').WorkbookEnvelope
152
+ type ChangesEnvelope = import('./transport/workbookApi.ts').ChangesEnvelope
153
+ type CollaborationReadyEnvelope = ChangesEnvelope & { workbookId?: string }
154
+ type HistoryEntry = import('./transport/workbookApi.ts').HistoryEntry
155
+ type HistoryVersion = import('../types.ts').WorkbookHistoryVersion
156
+
157
+ type SheetSaveState = 'loading' | 'saved' | 'saving' | 'error' | 'conflict'
158
+
159
+ type WorkbookDialog =
160
+ | { kind: 'create'; draft: string }
161
+ | { kind: 'rename'; id: string; draft: string }
162
+ | { kind: 'delete'; id: string }
163
+
164
+ /** Shared workbook workspace used by both the DSH details panel and the standalone page. */
165
+ export function WorkbookWorkspace({ open, visible = open, standalone = false, initialWorkbookId, bridge }: WorkbookWorkspaceProps) {
166
+ /**
167
+ * 「整页打开」的出口:浏览器里是新标签页,桌面端铺成应用内的一层。桌面壳不允许
168
+ * 插件开新窗口(`setWindowOpenHandler` 只放行 http/https),判断只在 `useFullPage` 里。
169
+ */
170
+ const fullPage = useFullPage()
171
+ // Resolve the workbook to reopen once, before the first paint, so switching
172
+ // DSH tabs shows the previous content instead of an empty panel.
173
+ const restoreTarget = ((): string => initialWorkbookId?.trim() || lastSelectedWorkbookId())()
174
+ const restored = cachedWorkbookProjection(restoreTarget)
175
+ const [snapshot, setSnapshot] = useState<object | null>(restored?.snapshot ?? null)
176
+ const [meta, setMeta] = useState<{ id: string; name: string } | null>(restored === null ? null : { id: restored.id, name: restored.name })
177
+ const [workbooks, setWorkbooks] = useState<WorkbookSummary[]>([])
178
+ const [revision, setRevision] = useState(restored?.revision ?? 0)
179
+ const [viewKey, setViewKey] = useState(0)
180
+ const [status, setStatus] = useState<SheetSaveState>('loading')
181
+ const [switching, setSwitching] = useState(false)
182
+ const [conflicts, setConflicts] = useState<MergeConflict[]>([])
183
+ const [notice, setNotice] = useState<string | null>(null)
184
+ const [historyOpen, setHistoryOpen] = useState(false)
185
+ const [historyLoading, setHistoryLoading] = useState(false)
186
+ const [historyVersions, setHistoryVersions] = useState<HistoryVersion[]>([])
187
+ const [expandedHistoryVersions, setExpandedHistoryVersions] = useState<Set<number>>(new Set())
188
+ /**
189
+ * The past Version the *canvas* is currently showing (PRD v3.2 §7.2). While
190
+ * this is set the workspace renders a read-only Univer residency loaded with
191
+ * `snapshot` from the fetch, the inspect bar is on screen, and nothing can be
192
+ * persisted — so neither preview nor comparison can fork a Version.
193
+ */
194
+ const [preview, setPreview] = useState<WorkbookPreviewState | null>(null)
195
+ const [previewLoading, setPreviewLoading] = useState(false)
196
+ /**
197
+ * What the inspected Version is measured against (PRD v3.5 §7.2). Preview
198
+ * *is* a comparison with a fixed other side, so there is no mode: the bar is
199
+ * always `版本 A → 版本 B`, and `baselineId === ''` — the current content — is
200
+ * what a preview means.
201
+ */
202
+ const [inspectBaselineId, setInspectBaselineId] = useState('')
203
+ // A baseline can be a Step, and Step is not implied by its Version: a Version
204
+ // with several Steps has several baselines, and the picker has to come back
205
+ // showing the one that was chosen.
206
+ const [inspectBaselineStep, setInspectBaselineStep] = useState<number | undefined>(undefined)
207
+ /**
208
+ * Incremented only when the inspected residency has to be rebuilt. Switching
209
+ * the inspected Version does **not** touch it: the canvas keeps its Univer
210
+ * instance and swaps the unit, so the grid does not flash and the scroll
211
+ * position survives (PRD v3.5 §7.2).
212
+ */
213
+ const [inspectRemount, setInspectRemount] = useState(0)
214
+ /** Pending restore / undo confirmation, with the copy its dialog needs. */
215
+ const [restoreRequest, setRestoreRequest] = useState<VersionRestoreRequest | null>(null)
216
+ const [restorePending, setRestorePending] = useState(false)
217
+ const [saveVersionOpen, setSaveVersionOpen] = useState(false)
218
+ const [saveVersionPending, setSaveVersionPending] = useState(false)
219
+ /** Comparison baseline; empty means "the current version" (PRD §8). */
220
+ const [compareAgainst, setCompareAgainst] = useState('')
221
+ const [presence, setPresence] = useState<WorkbookPresence[]>([])
222
+ const [toast, setToast] = useState<string | null>(null)
223
+ const [workbookDialog, setWorkbookDialog] = useState<WorkbookDialog | null>(null)
224
+ const [workbookActionPending, setWorkbookActionPending] = useState<'create' | 'rename' | 'delete' | null>(null)
225
+ const [deletingWorkbookId, setDeletingWorkbookId] = useState<string | null>(null)
226
+ const viewHandle = useRef<WorkbookCanvasHandle | null>(null)
227
+ const selectionRevisionRef = useRef(0)
228
+ const selectionSignatureRef = useRef('')
229
+ const pendingBridgeFocusRef = useRef<(WorkbookFocusRange & { workbookId: string }) | null>(null)
230
+ const bridgeFocusAttemptRef = useRef<string | null>(null)
231
+ /**
232
+ * Mirror of `preview` for the callbacks that captured an earlier render (the
233
+ * sync interval and the collaboration stream). Guards on the state value
234
+ * inside those callbacks would read `null` forever.
235
+ */
236
+ const previewRef = useRef<WorkbookPreviewState | null>(null)
237
+ useEffect(() => { previewRef.current = preview }, [preview])
238
+ // Remember the active workbook and its projection so a pane remount reopens it
239
+ // without a blank reload. This is only a cache: the mount effect revalidates.
240
+ useEffect(() => {
241
+ if (meta === null) return
242
+ rememberWorkbook(meta.id)
243
+ if (snapshot !== null) rememberWorkbookProjection({ id: meta.id, name: meta.name, snapshot, revision })
244
+ }, [meta, snapshot, revision])
245
+
246
+ /**
247
+ * Every Version row, newest-first, for the shared drawer. `stepsExpanded` has
248
+ * to be derived here (not inside the mapper) because it depends on local
249
+ * expansion state — the Steps are already in the history payload, so
250
+ * expanding needs no fetch. `open` Versions stay in this list because the
251
+ * drawer's *logic* needs them (an undo target is resolved against the real
252
+ * order); the rendered timeline filters them out.
253
+ */
254
+ const historyEntries = useMemo(() => historyVersions
255
+ .map((version, index) => {
256
+ const entry = toVersionManagerEntry(version, index, historyVersions)
257
+ return {
258
+ ...entry,
259
+ stepsExpanded: expandedHistoryVersions.has(version.revision),
260
+ steps: entry.steps?.map(step => ({
261
+ ...step,
262
+ selected: preview?.step === step.index && preview.versionId === version.versionId,
263
+ })),
264
+ }
265
+ })
266
+ .reverse(), [historyVersions, expandedHistoryVersions, preview])
267
+
268
+ /**
269
+ * The one flat timeline the drawer renders (PRD v3.3 §7.3): every named
270
+ * Version, the newest {@link AUTO_CHECKPOINT_KEEP} Versions, and the last
271
+ * Version of every calendar day — newest-first, with `今天/昨天/更早`
272
+ * separators.
273
+ *
274
+ * The editing session is included on purpose. It is the newest Version and
275
+ * the only one that accumulates Steps, so hiding it made every visible row
276
+ * look like a single-step snapshot and hid the work in progress. It is not a
277
+ * special card: it is a row like any other, and it simply has no 恢复 button
278
+ * because restoring the Version that is already current is meaningless.
279
+ */
280
+ const timelineEntries = useMemo<VersionManagerEntry[]>(
281
+ () => versionTimeline(historyEntries),
282
+ [historyEntries],
283
+ )
284
+
285
+ /** One flat list; `label: ''` renders it without a section header. */
286
+ const versionGroups = useMemo<VersionManagerGroup[]>(
287
+ () => (timelineEntries.length === 0 ? [] : [{ id: 'timeline', label: '', entries: timelineEntries }]),
288
+ [timelineEntries],
289
+ )
290
+
291
+ /** 「撤销这次改动」 on an Agent Version targets the Version before it (D5). */
292
+ const undoTargetFor = useCallback((entry: VersionManagerEntry) => {
293
+ const target = undoTargetOf(historyEntries, { id: entry.id, number: entry.number, status: entry.status })
294
+ return target === undefined ? undefined : { id: target.id, label: `V${target.number}`, replayable: target.replayable === true }
295
+ }, [historyEntries])
296
+
297
+ /** Newest Version overall — the open editing session included (PRD v3.1 §4). */
298
+ const headVersion = historyVersions.at(-1)
299
+ const headLabel = headVersion === undefined ? undefined : formatHistoryTime(headVersion.timestamp)
300
+ const namedVersionCount = timelineEntries.length
301
+ const headRevision = headVersion?.revision
302
+
303
+ /**
304
+ * The Version in use is the one still being written to, so its Steps are open
305
+ * without being asked for (item 6, GUI feedback 2026-09-19): they are the
306
+ * work currently in progress, and a collapsed list hid them behind a click.
307
+ * Seeding only ever *adds* the head revision — a Version the user opened by
308
+ * hand stays open, and one they closed by hand stays closed.
309
+ */
310
+ useEffect(() => {
311
+ if (!historyOpen || headRevision === undefined) return
312
+ setExpandedHistoryVersions(current => current.has(headRevision) ? current : new Set(current).add(headRevision))
313
+ }, [historyOpen, headRevision])
314
+
315
+ /**
316
+ * Preview switcher options: every Version the artifact could be swapped onto,
317
+ * newest-first, labelled the way its row was — built from the timeline so the
318
+ * selector can never offer a Version the list does not show.
319
+ */
320
+ const previewOptions = useMemo(() => inspectOptionsOf(timelineEntries), [timelineEntries])
321
+ const saveTimer = useRef<number | null>(null)
322
+ const dirtyRef = useRef(false)
323
+ const revisionRef = useRef(restored?.revision ?? 0)
324
+ const publishDataSheetSelection = useCallback((ranges: readonly WorkbookSelectionRange[]): void => {
325
+ if (bridge === undefined) return
326
+ const selection = meta === null || previewRef.current !== null
327
+ ? null
328
+ : projectDataSheetSelection({
329
+ workbookId: meta.id,
330
+ workbookName: meta.name,
331
+ revision: 0,
332
+ documentRevision: revisionRef.current,
333
+ ranges,
334
+ })
335
+ if (selection === null) {
336
+ selectionSignatureRef.current = ''
337
+ if (bridge.read(DATA_SHEET_SURFACE_ID) !== null) bridge.publish(DATA_SHEET_SURFACE_ID, null)
338
+ return
339
+ }
340
+ const signature = JSON.stringify(selection)
341
+ if (signature === selectionSignatureRef.current) return
342
+ selectionSignatureRef.current = signature
343
+ selectionRevisionRef.current += 1
344
+ bridge.publish(DATA_SHEET_SURFACE_ID, { ...selection, revision: selectionRevisionRef.current })
345
+ }, [bridge, meta])
346
+ useEffect(() => {
347
+ if (preview !== null) publishDataSheetSelection([])
348
+ }, [preview, publishDataSheetSelection])
349
+ const workbooksRef = useRef<WorkbookSummary[]>([])
350
+ // Seeded from the cached projection so a pane remount is recognised as a
351
+ // re-entry into the same workbook rather than a cold load.
352
+ const activeWorkbookIdRef = useRef<string | null>(restored?.id ?? null)
353
+ const mountedRef = useRef(true)
354
+ // Mirrors of the two states the bootstrap effect has to inspect without
355
+ // depending on them: `snapshot` identity changes on every revalidation, and a
356
+ // dependency on it re-ran the whole load (and the collaboration session) each
357
+ // time.
358
+ const snapshotRef = useRef<object | null>(restored?.snapshot ?? null)
359
+ const metaRef = useRef<{ id: string; name: string } | null>(restored === null ? null : { id: restored.id, name: restored.name })
360
+ const savingRef = useRef(false)
361
+ const serverRevisionRef = useRef(restored?.revision ?? 0)
362
+ const remoteOperationsRef = useRef(new Map<number, WorkbookOperation>())
363
+ const remoteSnapshotPendingRef = useRef(false)
364
+ const drainingRemoteRef = useRef(false)
365
+ const editGenerationRef = useRef(0)
366
+ const baselineRef = useRef<object | null>(restored === null ? null : cloneSnapshot(restored.snapshot))
367
+ const conflictsRef = useRef<MergeConflict[]>([])
368
+ const conflictStyleMapRef = useRef<Record<string, string | null>>({})
369
+ const pendingLocalChangesRef = useRef<WorkbookLocalChange[]>([])
370
+ /**
371
+ * The unretained editing session: which Version the next save joins, and how
372
+ * many Steps it already holds (PRD v3.3 D22). Seeded lazily from the store so
373
+ * a remount continues the same session instead of forking one per visit.
374
+ */
375
+ const sessionRef = useRef<VersionSession | null>(null)
376
+ const sessionWorkbookIdRef = useRef<string>('')
377
+ const setSession = (next: VersionSession | null): void => {
378
+ sessionRef.current = next
379
+ rememberVersionSession(sessionWorkbookIdRef.current, next)
380
+ }
381
+ const seedSession = (workbookId: string): void => {
382
+ sessionWorkbookIdRef.current = workbookId
383
+ sessionRef.current = readVersionSession(workbookId)
384
+ }
385
+ const sessionVersionId = (): string | null => sessionRef.current?.versionId ?? null
386
+ const sessionStepCount = (): number => sessionRef.current?.stepCount ?? 0
387
+ /**
388
+ * Adopt the Host's answer about the session after a whole-snapshot commit
389
+ * (import, conflict merge). The cursor must survive the commit — the Host
390
+ * joins the newest open Version — and only a `final` answer closes it.
391
+ */
392
+ const adoptSession = (result: SaveResult | null, fallbackVersionId: string | null, stepCount: number): void => {
393
+ const versionId = result?.operation?.versionId ?? fallbackVersionId
394
+ if (versionId === null || result?.operation?.versionStatus === 'final') {
395
+ setSession(null)
396
+ return
397
+ }
398
+ setSession({ versionId, stepCount })
399
+ }
400
+ const snapshotSaveRequiredRef = useRef(false)
401
+ const pendingSavePromiseRef = useRef<Promise<void> | null>(null)
402
+ const flushPendingSaveRef = useRef<(() => Promise<void>) | null>(null)
403
+ const presenceTimerRef = useRef<number | null>(null)
404
+ const pendingPresenceRef = useRef<WorkbookPresencePayload | null>(null)
405
+ const toastTimerRef = useRef<number | null>(null)
406
+
407
+ useEffect(() => { snapshotRef.current = snapshot }, [snapshot])
408
+ useEffect(() => { metaRef.current = meta }, [meta])
409
+ /** Whether a projection is on screen; a stable gate for the sync session. */
410
+ const hasSnapshot = snapshot !== null
411
+
412
+ useEffect(() => () => {
413
+ mountedRef.current = false
414
+ if (presenceTimerRef.current !== null) window.clearTimeout(presenceTimerRef.current)
415
+ if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current)
416
+ }, [])
417
+
418
+ const setCurrentRevision = (next: number): void => {
419
+ revisionRef.current = next
420
+ serverRevisionRef.current = Math.max(serverRevisionRef.current, next)
421
+ if (mountedRef.current) setRevision(next)
422
+ }
423
+
424
+ /**
425
+ * The op ids this browser sent and has not seen come back, so
426
+ * `drainRemoteOperations` can tell its own echo from a peer's edit. See
427
+ * `createOwnOpLog` for why that is what keeps a save from flashing the grid.
428
+ */
429
+ const ownOpsRef = useRef(createOwnOpLog())
430
+ const userOpId = (id: string): string => ownOpsRef.current.next(id)
431
+
432
+ const setMergeConflicts = (next: MergeConflict[], styleMap: Record<string, string | null> = {}): void => {
433
+ conflictsRef.current = next
434
+ conflictStyleMapRef.current = styleMap
435
+ if (mountedRef.current) setConflicts(next)
436
+ }
437
+
438
+ const schedulePresence = (next: WorkbookPresencePayload): void => {
439
+ if (meta === null) return
440
+ pendingPresenceRef.current = next
441
+ if (presenceTimerRef.current !== null) window.clearTimeout(presenceTimerRef.current)
442
+ presenceTimerRef.current = window.setTimeout(() => {
443
+ presenceTimerRef.current = null
444
+ const pending = pendingPresenceRef.current
445
+ pendingPresenceRef.current = null
446
+ if (pending === null || meta === null) return
447
+ void publishPresence(meta.id, pending).catch(() => { /* presence is best-effort */ })
448
+ }, 120)
449
+ }
450
+
451
+ const showToast = (message: string, durationMs = 2200): void => {
452
+ setToast(message)
453
+ if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current)
454
+ toastTimerRef.current = window.setTimeout(() => {
455
+ toastTimerRef.current = null
456
+ if (mountedRef.current) setToast(null)
457
+ }, durationMs)
458
+ }
459
+
460
+ /**
461
+ * Refuse a *deliberate* write that cannot land: naming a Version, importing,
462
+ * or merging while a past Version is on screen. A two-second toast, because
463
+ * this is one refused action rather than a state to keep announcing.
464
+ *
465
+ * Editing the read-only grid on purpose is **not** this: that is Univer's
466
+ * workbook-level protection, the same mechanism a protected sheet uses, and
467
+ * it says nothing — a spreadsheet does not narrate a lock (GUI feedback
468
+ * 2026-09-19).
469
+ */
470
+ const refuseEdit = (): void => { showToast('该状态无法编辑', 2000) }
471
+
472
+ const loadHistory = async (id: string): Promise<void> => {
473
+ setHistoryLoading(true)
474
+ try {
475
+ const value = await fetchHistory(id)
476
+ if (mountedRef.current && activeWorkbookIdRef.current === id) {
477
+ setHistoryVersions(value.versions ?? historyVersionsFromEntries(value.entries ?? []))
478
+ }
479
+ } catch (error) {
480
+ if (mountedRef.current) setNotice(error instanceof Error ? error.message : String(error))
481
+ } finally {
482
+ if (mountedRef.current) setHistoryLoading(false)
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Put a past Version on the canvas (PRD v3.2 §7.2). Preview is no longer a
488
+ * read-only screen inside the drawer: the fetched content state is handed to a
489
+ * read-only Univer residency, and the preview bar above it says what is on
490
+ * screen and lets the user switch without reopening the list.
491
+ *
492
+ * `entryId` is a Version id rather than a row, because the switcher in the bar
493
+ * addresses Versions the same way the list does.
494
+ */
495
+ /** Revision a baseline id measures against; `''` is the live content. */
496
+ const baselineOf = useCallback((baselineId: string, baselineStep?: number): number => {
497
+ if (baselineId === '') return revisionRef.current
498
+ return baselineRevisionOf(
499
+ timelineEntries.find(candidate => candidate.id === baselineId),
500
+ revisionRef.current,
501
+ baselineStep,
502
+ )
503
+ }, [timelineEntries])
504
+
505
+ const previewVersion = useCallback(async (entryId: string, step?: number, baselineOverride?: string, baselineStep?: number): Promise<void> => {
506
+ if (meta === null) return
507
+ const entry = historyVersions.find(candidate => candidate.versionId === entryId)
508
+ if (entry === undefined || entry.canPreview !== true) return
509
+ setPreviewLoading(true)
510
+ try {
511
+ // Entering preview unmounts the live residency, so any edit that has not
512
+ // reached the server yet has to be committed first — otherwise it would
513
+ // silently disappear from the canvas when the preview ends. The ref keeps
514
+ // this out of the callback's dependency list (it closes over refs only).
515
+ if (dirtyRef.current) await flushPendingSaveRef.current?.()
516
+ const value = await fetchHistorySnapshot(meta.id, entry.revision, step)
517
+ if (value.snapshot === undefined) throw new Error('历史快照缺失')
518
+ // A Step preview already carries what that Step changed, measured against
519
+ // the state before it. A whole-Version view is measured against the chosen
520
+ // baseline — the current content by default, because "how is this
521
+ // different from what I have now" is the question a preview asks.
522
+ let changes: WorkbookChange[] = value.changes ?? []
523
+ let changesUnavailable = false
524
+ let changesTruncated = value.truncated === true
525
+ let changesSide: 'before' | 'after' = 'after'
526
+ if (step === undefined) {
527
+ const against = baselineOverride === undefined
528
+ ? baselineOf(inspectBaselineId, inspectBaselineStep)
529
+ : baselineOf(baselineOverride, baselineStep)
530
+ const from = Math.min(against, entry.revision)
531
+ const to = Math.max(against, entry.revision)
532
+ // The canvas always shows `entry`, so that is the side whose changes
533
+ // are visible: the newer one, or the older one when previewing history
534
+ // against the current content.
535
+ changesSide = entry.revision >= against ? 'after' : 'before'
536
+ try {
537
+ if (from === to) {
538
+ changes = []
539
+ changesTruncated = false
540
+ } else {
541
+ const diff = await fetchHistoryDiff(meta.id, from, to)
542
+ changes = diff.changes ?? []
543
+ changesTruncated = diff.truncated === true
544
+ }
545
+ } catch {
546
+ changes = []
547
+ changesUnavailable = true
548
+ changesTruncated = false
549
+ }
550
+ }
551
+ const next = {
552
+ versionId: entry.versionId,
553
+ revision: value.revision ?? entry.revision,
554
+ snapshot: value.snapshot,
555
+ step: step ?? null,
556
+ changes,
557
+ changesSide,
558
+ ...(changesUnavailable ? { changesUnavailable: true } : {}),
559
+ ...(changesTruncated ? { changesTruncated: true } : {}),
560
+ }
561
+ // Switch content inside the residency when possible: changing what is
562
+ // inspected is "different cells", not "a different application", and
563
+ // rebuilding Univer for it is what made every switch flash
564
+ // (PRD v3.5 §7.2). Only a failed swap, or entering inspect from live,
565
+ // rebuilds the canvas.
566
+ const swapped = preview !== null && viewHandle.current?.swapWorkbook(next.snapshot, toDecoration(changes, changesSide)) === true
567
+ if (!swapped) setInspectRemount(count => count + 1)
568
+ setPreview(next)
569
+ // The bar lives above the canvas, so the drawer has to get out of the way
570
+ // — leaving it open would cover the content being inspected.
571
+ setHistoryOpen(false)
572
+ } catch (error) {
573
+ setNotice(error instanceof Error ? error.message : String(error))
574
+ } finally {
575
+ if (mountedRef.current) setPreviewLoading(false)
576
+ }
577
+ }, [baselineOf, historyVersions, inspectBaselineId, inspectBaselineStep, meta, preview])
578
+
579
+ /** Leave inspect and hand the canvas back to the live projection. */
580
+ const exitPreview = useCallback((): void => {
581
+ setPreview(null)
582
+ // Re-read the live projection: the collaboration stream keeps `snapshot`
583
+ // current while inspecting, and remounting is the only way Univer swaps
584
+ // workbooks. A bumped key also covers a residency that never changed it.
585
+ setViewKey(key => key + 1)
586
+ }, [])
587
+
588
+ /**
589
+ * The canvas overlay: identical to what an in-place swap painted, so a
590
+ * remount and a swap show exactly the same thing.
591
+ */
592
+ const previewDecoration = useMemo(() => previewDecorationOf(preview), [preview])
593
+
594
+ /** Counts + legend for the bar, derived from the same list the canvas tints. */
595
+ const previewSummary = useMemo(() => inspectorSummaryOf(preview), [preview])
596
+
597
+
598
+ /** The row's single primary action opens the shared confirmation dialog. */
599
+ const requestRestore = (entry: VersionManagerEntry, intent: VersionRestoreIntent): void => {
600
+ const target = historyVersions.find(candidate => candidate.versionId === intent.targetId)
601
+ if (target === undefined) return
602
+ setRestoreRequest({
603
+ revision: target.revision,
604
+ label: intent.targetLabel,
605
+ // Same copy in both plugins: the Version records where it came from, and
606
+ // the dialog is what explains an undo (PRD v3 §8). The record gets the
607
+ // Version *number*, never the dialog's render-time label: a relative day
608
+ // word written into history is wrong by the next morning.
609
+ summary: restoreVersionSummary(target.number),
610
+ mode: intent.mode,
611
+ })
612
+ }
613
+
614
+ /** What each row's links do; `restorableId` is filled in by the drawer. */
615
+ const versionActions: VersionManagerActions = {
616
+ onPreview: entry => { void previewVersion(entry.id) },
617
+ onPreviewStep: (entry, step) => { void previewVersion(entry.id, step.index) },
618
+ onRestore: requestRestore,
619
+ undoTargetFor,
620
+ ...(preview === null ? {} : { previewingId: preview.versionId }),
621
+ onToggleSteps: entry => setExpandedHistoryVersions(current => {
622
+ const revision = entry.revision ?? -1
623
+ const next = new Set(current)
624
+ if (next.has(revision)) next.delete(revision)
625
+ else next.add(revision)
626
+ return next
627
+ }),
628
+ }
629
+
630
+ const restoreHistory = async (request: { revision: number; label: string; summary: string; mode: 'restore' | 'undo' }): Promise<void> => {
631
+ if (meta === null || request.revision === revisionRef.current) return
632
+ setHistoryLoading(true)
633
+ try {
634
+ await rollbackWorkbook(meta.id, request.revision, revisionRef.current, request.summary)
635
+ // A restore records its own deliberate Version; the open session is over.
636
+ setSession(null)
637
+ await reloadSnapshot(meta.id, true)
638
+ setPreview(null)
639
+ setStatus('saved')
640
+ setNotice(request.mode === 'undo'
641
+ ? '已撤销这次改动,历史版本仍然保留。'
642
+ : `已恢复到 ${request.label},历史版本仍然保留。`)
643
+ await loadHistory(meta.id)
644
+ } catch (error) {
645
+ setStatus(error instanceof RevisionConflictError ? 'conflict' : 'error')
646
+ setNotice(error instanceof Error ? error.message : String(error))
647
+ } finally {
648
+ if (mountedRef.current) setHistoryLoading(false)
649
+ }
650
+ }
651
+
652
+ // Univer's history command is registered by the open-source Datasheet
653
+ // plugin and asks this host-owned panel to load the durable operation log.
654
+ useEffect(() => {
655
+ if (!open || meta === null) return
656
+ const onOpenHistory = (): void => {
657
+ setHistoryOpen(true)
658
+ void loadHistory(meta.id)
659
+ }
660
+ const onCommandError = (event: Event): void => {
661
+ const message = (event as CustomEvent<{ message?: string }>).detail?.message
662
+ if (message !== undefined) {
663
+ setStatus('error')
664
+ setNotice(message)
665
+ }
666
+ }
667
+ window.addEventListener('data-sheet:history', onOpenHistory)
668
+ window.addEventListener('data-sheet:command-error', onCommandError)
669
+ return () => {
670
+ window.removeEventListener('data-sheet:history', onOpenHistory)
671
+ window.removeEventListener('data-sheet:command-error', onCommandError)
672
+ }
673
+ }, [meta, open])
674
+
675
+ const reloadSnapshot = async (id: string, remount: boolean): Promise<void> => {
676
+ if (saveTimer.current !== null) {
677
+ window.clearTimeout(saveTimer.current)
678
+ saveTimer.current = null
679
+ }
680
+ pendingLocalChangesRef.current = []
681
+ snapshotSaveRequiredRef.current = false
682
+ remoteOperationsRef.current.clear()
683
+ remoteSnapshotPendingRef.current = false
684
+ const envelope = await fetchWorkbook(id)
685
+ if (envelope.snapshot === undefined) throw new Error('workbook snapshot 缺失')
686
+ if (!mountedRef.current) return
687
+ const name = workbookName(id, workbooksRef.current)
688
+ const nextSnapshot = withWorkbookName(envelope.snapshot, name)
689
+ baselineRef.current = cloneSnapshot(nextSnapshot)
690
+ setMergeConflicts([])
691
+ setSnapshot(nextSnapshot)
692
+ activeWorkbookIdRef.current = id
693
+ serverRevisionRef.current = envelope.revision ?? 0
694
+ setMeta({ id, name })
695
+ setCurrentRevision(envelope.revision ?? 0)
696
+ dirtyRef.current = false
697
+ setNotice(null)
698
+ if (remount) setViewKey(key => key + 1)
699
+ }
700
+
701
+ /**
702
+ * Adopt a snapshot the Host has just stored, instead of re-downloading it.
703
+ *
704
+ * The save response carries the new revision and `viewHandle.save()` is what
705
+ * was stored, so a fetch would only return the snapshot the canvas already
706
+ * shows — and `reloadSnapshot`'s `remount` argument would then bump `viewKey`
707
+ * and rebuild the Univer residency for nothing. Queued remote operations at or
708
+ * below the new revision are dropped the way a cell save drops them, and
709
+ * `remoteSnapshotPending` is cleared because this *is* what it waited for.
710
+ */
711
+ const adoptStoredSnapshot = (id: string, snapshotData: object, revision: number): void => {
712
+ const next = withWorkbookName(snapshotData, workbookName(id, workbooksRef.current))
713
+ baselineRef.current = cloneSnapshot(next)
714
+ snapshotRef.current = next
715
+ setSnapshot(next)
716
+ remoteSnapshotPendingRef.current = false
717
+ setMergeConflicts([])
718
+ for (const pendingRevision of [...remoteOperationsRef.current.keys()]) {
719
+ if (pendingRevision <= revision) remoteOperationsRef.current.delete(pendingRevision)
720
+ }
721
+ serverRevisionRef.current = Math.max(serverRevisionRef.current, revision)
722
+ setCurrentRevision(revision)
723
+ }
724
+
725
+ /**
726
+ * Bootstrap the browser projection.
727
+ *
728
+ * This effect deliberately does **not** depend on `snapshot`: `snapshot` gets a
729
+ * fresh object identity on every load, so depending on it re-ran the request
730
+ * that produced it, forever — a reload loop that also tore down and rebuilt
731
+ * the collaboration session on every iteration. `snapshotRef` carries the few
732
+ * facts the effect needs.
733
+ *
734
+ * A re-entry into a workbook that is already painted (the resident workspace
735
+ * never disappeared, or the cached projection is still valid) only refreshes
736
+ * the catalog: the collaboration stream reconciles any missed operations
737
+ * through `/changes`, which is far cheaper than re-downloading the snapshot.
738
+ */
739
+ useEffect(() => {
740
+ mountedRef.current = true
741
+ if (!open) return
742
+ let cancelled = false
743
+ void (async () => {
744
+ const catalog = await fetchWorkbooks().catch(() => ({ workbooks: [] }))
745
+ const choices = catalog.workbooks ?? []
746
+ workbooksRef.current = choices
747
+ if (!cancelled) setWorkbooks(choices)
748
+ // An explicit target wins; otherwise reopen whatever the user last had,
749
+ // so switching DSH tabs does not silently deselect the workbook.
750
+ const requestedWorkbookId = initialWorkbookId?.trim() || lastSelectedWorkbookId()
751
+ if (requestedWorkbookId.length === 0) {
752
+ if (!cancelled) {
753
+ setStatus('saved')
754
+ setNotice(choices.length === 0 ? '暂无工作簿,请先创建工作簿。' : '请选择要打开的工作簿。')
755
+ }
756
+ return
757
+ }
758
+ // Already showing this workbook: keep the projection and let the sync
759
+ // session catch up. Replacing it here is what made every visit flash.
760
+ if (activeWorkbookIdRef.current === requestedWorkbookId && snapshotRef.current !== null) {
761
+ if (cancelled) return
762
+ const name = workbookName(requestedWorkbookId, choices)
763
+ if (metaRef.current !== null && metaRef.current.name !== name) setMeta({ id: requestedWorkbookId, name })
764
+ setStatus('saved')
765
+ return
766
+ }
767
+ try {
768
+ const envelope = await fetchWorkbook(requestedWorkbookId)
769
+ if (!cancelled && envelope.snapshot !== undefined) {
770
+ const name = workbookName(requestedWorkbookId, choices)
771
+ const nextSnapshot = withWorkbookName(envelope.snapshot, name)
772
+ baselineRef.current = cloneSnapshot(nextSnapshot)
773
+ setMergeConflicts([])
774
+ snapshotRef.current = nextSnapshot
775
+ setSnapshot(nextSnapshot)
776
+ activeWorkbookIdRef.current = requestedWorkbookId
777
+ // A cold load continues whatever unretained session this workbook
778
+ // left open, so closing and reopening the tab does not fork a Version.
779
+ seedSession(requestedWorkbookId)
780
+ serverRevisionRef.current = envelope.revision ?? 0
781
+ metaRef.current = { id: requestedWorkbookId, name }
782
+ setMeta({ id: requestedWorkbookId, name })
783
+ setCurrentRevision(envelope.revision ?? 0)
784
+ setStatus('saved')
785
+ }
786
+ } catch (error) {
787
+ // A failed revalidation must not throw away the projection already on
788
+ // screen; it only reports the failure.
789
+ if (!cancelled) {
790
+ setStatus('error')
791
+ if (error instanceof HttpStatusError && error.status === 404) {
792
+ rememberWorkbook('')
793
+ forgetWorkbookProjection(requestedWorkbookId)
794
+ activeWorkbookIdRef.current = null
795
+ snapshotRef.current = null
796
+ }
797
+ setNotice(error instanceof HttpStatusError && error.status === 404
798
+ ? `工作簿「${requestedWorkbookId}」不存在,请先创建后再打开。`
799
+ : error instanceof Error ? error.message : String(error))
800
+ }
801
+ }
802
+ })()
803
+ return () => { cancelled = true }
804
+ }, [initialWorkbookId, open])
805
+
806
+ // Refresh the explicit workbook catalog without opening a default workbook.
807
+ useEffect(() => {
808
+ if (!open) return
809
+ const refresh = async (): Promise<void> => {
810
+ try {
811
+ const choices = (await fetchWorkbooks()).workbooks ?? []
812
+ workbooksRef.current = choices
813
+ if (mountedRef.current) setWorkbooks(choices)
814
+ } catch {
815
+ // A temporary catalog failure should not interrupt editing.
816
+ }
817
+ }
818
+ const timer = window.setInterval(() => { void refresh() }, 5000)
819
+ return () => window.clearInterval(timer)
820
+ }, [open])
821
+
822
+ /** Merge a failed local save against the newest server snapshot. */
823
+ const mergeWithLatestRemote = async (id: string, localSnapshot: object, retry = 0): Promise<SaveResult | null> => {
824
+ pendingLocalChangesRef.current = []
825
+ snapshotSaveRequiredRef.current = false
826
+ // The merged result is committed as a whole-snapshot Step; the session it
827
+ // joins is the one already open, so the cursor stays where it is.
828
+ const envelope = await fetchWorkbook(id)
829
+ if (envelope.snapshot === undefined) throw new Error('远端 workbook snapshot 缺失')
830
+ const name = workbookName(id, workbooksRef.current)
831
+ const remoteSnapshot = withWorkbookName(envelope.snapshot, name)
832
+ const baseSnapshot = baselineRef.current ?? cloneSnapshot(remoteSnapshot)
833
+ const normalizedLocal = stripConflictMarkers(localSnapshot, conflictStyleMapRef.current)
834
+ const merged = mergeWorkbookSnapshots(baseSnapshot, normalizedLocal, remoteSnapshot)
835
+ const mergedSnapshot = withWorkbookName(merged.snapshot, name)
836
+ const remoteRevision = envelope.revision ?? 0
837
+
838
+ baselineRef.current = cloneSnapshot(remoteSnapshot)
839
+ activeWorkbookIdRef.current = id
840
+ setCurrentRevision(remoteRevision)
841
+ setMergeConflicts(merged.conflicts, merged.conflictStyleMap)
842
+ setSnapshot(mergedSnapshot)
843
+ setViewKey(key => key + 1)
844
+ dirtyRef.current = true
845
+
846
+ if (merged.conflicts.length > 0) {
847
+ setStatus('conflict')
848
+ setNotice(`已自动合并 ${merged.autoMergedCells} 个单元格;发现 ${merged.conflicts.length} 个冲突,冲突单元格已保留本地值并标记。请处理后提交合并结果。`)
849
+ return null
850
+ }
851
+
852
+ savingRef.current = true
853
+ setStatus('saving')
854
+ try {
855
+ const result = await persistSnapshotForTarget(id, mergedSnapshot, remoteRevision, userOpId(id))
856
+ const cleanSnapshot = stripConflictMarkers(mergedSnapshot, merged.conflictStyleMap)
857
+ baselineRef.current = cloneSnapshot(cleanSnapshot)
858
+ setSnapshot(cleanSnapshot)
859
+ setMergeConflicts([])
860
+ pendingLocalChangesRef.current = []
861
+ adoptSession(result, sessionVersionId(), sessionStepCount() + 1)
862
+ snapshotSaveRequiredRef.current = false
863
+ dirtyRef.current = false
864
+ if (result !== null) setCurrentRevision(result.revision)
865
+ setStatus('saved')
866
+ setNotice(`已自动合并远端修改${merged.autoMergedCells > 0 ? `,合并 ${merged.autoMergedCells} 个单元格` : ''}。`)
867
+ return result
868
+ } catch (error) {
869
+ if (error instanceof RevisionConflictError && retry < 1) return mergeWithLatestRemote(id, mergedSnapshot, retry + 1)
870
+ throw error
871
+ } finally {
872
+ savingRef.current = false
873
+ void drainRemoteOperations()
874
+ }
875
+ }
876
+
877
+ /** Explicitly commit a merged canvas after the user reviews marked cells. */
878
+ const commitMergedSnapshot = async (): Promise<void> => {
879
+ if (meta === null || conflictsRef.current.length === 0) return
880
+ const localSnapshot = viewHandle.current?.save()
881
+ if (localSnapshot === undefined || localSnapshot === null || typeof localSnapshot !== 'object') return
882
+ savingRef.current = true
883
+ setStatus('saving')
884
+ const cleanSnapshot = stripConflictMarkers(localSnapshot as object, conflictStyleMapRef.current)
885
+ try {
886
+ const result = await persistSnapshotForTarget(meta.id, cleanSnapshot, revisionRef.current, userOpId(meta.id))
887
+ baselineRef.current = cloneSnapshot(cleanSnapshot)
888
+ setSnapshot(cleanSnapshot)
889
+ setViewKey(key => key + 1)
890
+ setMergeConflicts([])
891
+ pendingLocalChangesRef.current = []
892
+ adoptSession(result, sessionVersionId(), sessionStepCount() + 1)
893
+ snapshotSaveRequiredRef.current = false
894
+ dirtyRef.current = false
895
+ if (result !== null) setCurrentRevision(result.revision)
896
+ setStatus('saved')
897
+ setNotice('已提交三方合并结果,冲突标记已清除。')
898
+ } catch (error) {
899
+ if (error instanceof RevisionConflictError) {
900
+ try {
901
+ await mergeWithLatestRemote(meta.id, localSnapshot as object)
902
+ } catch (mergeError) {
903
+ setStatus('conflict')
904
+ setNotice(mergeError instanceof Error ? mergeError.message : String(mergeError))
905
+ }
906
+ } else {
907
+ setStatus('error')
908
+ setNotice(error instanceof Error ? error.message : String(error))
909
+ }
910
+ } finally {
911
+ savingRef.current = false
912
+ void drainRemoteOperations()
913
+ }
914
+ }
915
+
916
+ const applyLocalChangesToCanvas = async (changes: WorkbookLocalChange[], id: string, revision: number): Promise<void> => {
917
+ const handle = viewHandle.current
918
+ if (handle === null) return
919
+ for (const change of changes) {
920
+ await handle.applyOperation({
921
+ opId: `local-preview:${id}:${revision}:${Math.random().toString(36).slice(2)}`,
922
+ workbookId: id,
923
+ revision,
924
+ actor: 'user',
925
+ type: change.type,
926
+ payload: change.payload,
927
+ timestamp: new Date().toISOString(),
928
+ })
929
+ }
930
+ }
931
+
932
+ const savePendingUserChanges = async (requestedId?: string): Promise<void> => {
933
+ const id = requestedId ?? meta?.id
934
+ if (id === undefined || id.length === 0) return
935
+ // A single in-flight save owns the current revision. New edits remain in
936
+ // the queue and are drained by the finally block below.
937
+ if (savingRef.current) return
938
+ if (!dirtyRef.current || conflictsRef.current.length > 0) return
939
+ const pending = pendingLocalChangesRef.current
940
+ const forceSnapshot = snapshotSaveRequiredRef.current
941
+ if (!forceSnapshot && pending.length === 0) return
942
+ const versionStepCount = sessionStepCount()
943
+ const versionId = sessionVersionId() ?? nextUserVersionId(id)
944
+ const remainingVersionSlots = Math.max(1, HUMAN_VERSION_STEP_LIMIT - versionStepCount)
945
+ const sentChanges = forceSnapshot ? [] : pending.slice(0, remainingVersionSlots)
946
+ const nextVersionStepCount = versionStepCount + sentChanges.length
947
+ const versionStatus = nextVersionStepCount >= HUMAN_VERSION_STEP_LIMIT ? 'final' as const : 'open' as const
948
+ const savedGeneration = editGenerationRef.current
949
+ let snap: object | null = null
950
+ if (forceSnapshot) {
951
+ const value = viewHandle.current?.save()
952
+ snap = value !== undefined && value !== null && typeof value === 'object' ? value as object : null
953
+ if (snap === null) return
954
+ }
955
+ pendingLocalChangesRef.current = forceSnapshot ? [] : pending.slice(sentChanges.length)
956
+ snapshotSaveRequiredRef.current = false
957
+ savingRef.current = true
958
+ try {
959
+ const opId = userOpId(id)
960
+ const result = forceSnapshot
961
+ ? await saveSnapshot(id, snap, revisionRef.current, opId, { versionId, versionStatus, versionKind: 'auto' })
962
+ : await saveOperationChanges(id, sentChanges, revisionRef.current, opId, { id: versionId, status: versionStatus })
963
+ if (result === null || !mountedRef.current || activeWorkbookIdRef.current !== id) return
964
+ let effectiveVersionStatus: 'open' | 'final' = forceSnapshot ? 'final' : versionStatus
965
+ let effectiveVersionStepCount = nextVersionStepCount
966
+ if (forceSnapshot && snap !== null) {
967
+ const cleanSnapshot = stripConflictMarkers(snap, conflictStyleMapRef.current)
968
+ baselineRef.current = cloneSnapshot(cleanSnapshot)
969
+ setMergeConflicts([])
970
+ } else if (baselineRef.current !== null) {
971
+ // The server may have accepted this cell edit on top of concurrent
972
+ // remote cell edits. Project those remote edits first so the live
973
+ // Univer canvas and the merge base do not lose them.
974
+ for (const remoteOperation of result.rebasedOperations ?? []) {
975
+ remoteOperationsRef.current.delete(remoteOperation.revision)
976
+ try { await viewHandle.current?.applyOperation(remoteOperation) } catch { /* the next sync can reload a snapshot */ }
977
+ applyIncrementalOperationsToSnapshot(baselineRef.current, [remoteOperation])
978
+ }
979
+ applyIncrementalChangesToSnapshot(baselineRef.current, sentChanges)
980
+ // The server applies the local request after all rebased operations.
981
+ // Replay it in the live canvas as well so the visual projection has
982
+ // exactly the same last-writer-wins result as the durable snapshot.
983
+ await applyLocalChangesToCanvas(sentChanges, id, result.revision)
984
+ }
985
+ if (!forceSnapshot) {
986
+ const committedVersionId = result.operation?.versionId ?? versionId
987
+ const storedVersionStatus = result.operation?.versionStatus ?? versionStatus
988
+ // A just-upgraded browser may receive a legacy `temporary` status
989
+ // from an older Host during rolling upgrades. Treat it as `open`.
990
+ const committedVersionStatus = storedVersionStatus === 'final' ? 'final' as const : 'open' as const
991
+ effectiveVersionStatus = committedVersionStatus
992
+ effectiveVersionStepCount = committedVersionId === versionId ? nextVersionStepCount : sentChanges.length
993
+ if (committedVersionStatus === 'final') {
994
+ setSession(null)
995
+ } else {
996
+ setSession({ versionId: committedVersionId, stepCount: effectiveVersionStepCount })
997
+ }
998
+ } else {
999
+ // A whole-snapshot save is one Step of the editing session (PRD v3.1
1000
+ // §4): keep joining the same Version so a session stays one Version.
1001
+ const committedVersionId = result.operation?.versionId ?? versionId
1002
+ const storedVersionStatus = result.operation?.versionStatus
1003
+ if (storedVersionStatus === 'final') {
1004
+ setSession(null)
1005
+ } else {
1006
+ setSession({ versionId: committedVersionId, stepCount: versionStepCount + 1 })
1007
+ }
1008
+ }
1009
+ const hasNewerEdits = editGenerationRef.current !== savedGeneration
1010
+ || pendingLocalChangesRef.current.length > 0 || snapshotSaveRequiredRef.current
1011
+ dirtyRef.current = hasNewerEdits
1012
+ setCurrentRevision(result.revision)
1013
+ serverRevisionRef.current = Math.max(serverRevisionRef.current, result.revision)
1014
+ for (const revision of [...remoteOperationsRef.current.keys()]) {
1015
+ if (revision <= result.revision) remoteOperationsRef.current.delete(revision)
1016
+ }
1017
+ setStatus(hasNewerEdits ? 'saving' : 'saved')
1018
+ if (!hasNewerEdits) {
1019
+ // Clear a stale network/conflict message before showing the compact
1020
+ // success toast; otherwise both notices would stack at the same edge.
1021
+ setNotice(null)
1022
+ // The top menu row already carries a permanent 已保存 indicator at its
1023
+ // right edge, so a plain success toast only duplicated it. Keep the
1024
+ // toast solely for the case it cannot express: the edits are saved but
1025
+ // still sit in the open editing session, which the version list does
1026
+ // not show until it is named (PRD v3.2 §7.3).
1027
+ if (effectiveVersionStatus === 'open') {
1028
+ showToast(`已保存 · 编辑会话中(${effectiveVersionStepCount}/${HUMAN_VERSION_STEP_LIMIT} 步),命名后进版本清单`)
1029
+ }
1030
+ }
1031
+ } catch (error) {
1032
+ if (error instanceof RevisionConflictError) {
1033
+ const latestLocal = viewHandle.current?.save()
1034
+ try {
1035
+ await mergeWithLatestRemote(id, (latestLocal !== undefined && latestLocal !== null ? latestLocal : snap ?? baselineRef.current) as object)
1036
+ } catch (mergeError) {
1037
+ if (mountedRef.current && activeWorkbookIdRef.current === id) {
1038
+ setStatus('conflict')
1039
+ setNotice(mergeError instanceof Error ? mergeError.message : String(mergeError))
1040
+ }
1041
+ }
1042
+ return
1043
+ }
1044
+ // Keep a failed incremental edit queued so a later edit/flush can retry
1045
+ // it without forcing the user to reload the workbook.
1046
+ pendingLocalChangesRef.current = [...sentChanges, ...pendingLocalChangesRef.current]
1047
+ if (forceSnapshot) snapshotSaveRequiredRef.current = true
1048
+ if (mountedRef.current && activeWorkbookIdRef.current === id) {
1049
+ setStatus('error')
1050
+ setNotice(error instanceof Error ? error.message : '保存失败,请稍后重试。')
1051
+ }
1052
+ throw error
1053
+ } finally {
1054
+ savingRef.current = false
1055
+ if (mountedRef.current && activeWorkbookIdRef.current === id && dirtyRef.current
1056
+ && conflictsRef.current.length === 0 && saveTimer.current === null && pendingSavePromiseRef.current === null) {
1057
+ const nextPromise = savePendingUserChanges(id)
1058
+ pendingSavePromiseRef.current = nextPromise
1059
+ void nextPromise.finally(() => {
1060
+ if (pendingSavePromiseRef.current === nextPromise) pendingSavePromiseRef.current = null
1061
+ }).catch(() => { /* status is already shown by savePendingUserChanges */ })
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ const scheduleSave = (change?: WorkbookLocalChange): void => {
1067
+ // A preview canvas is read-only (`WorkbookCanvas readOnly`) and never calls
1068
+ // this, but the guard is what makes "a preview cannot fork a Version" a
1069
+ // property of the panel rather than of Univer's permission plugin.
1070
+ if (preview !== null) return
1071
+ if (conflictsRef.current.length > 0) {
1072
+ dirtyRef.current = true
1073
+ setStatus('conflict')
1074
+ setNotice('当前仍有三方合并冲突,请处理标记单元格后点击「提交合并结果」。')
1075
+ return
1076
+ }
1077
+ editGenerationRef.current += 1
1078
+ dirtyRef.current = true
1079
+ if (change !== undefined && change.payload.cells.length > 0) pendingLocalChangesRef.current.push(change)
1080
+ else snapshotSaveRequiredRef.current = true
1081
+ setStatus('saving')
1082
+ // A human Version is created only on Ctrl/Cmd+S or after ten mutation
1083
+ // steps. Selection/focus events never reach this callback and therefore
1084
+ // cannot trigger a save.
1085
+ const pendingStepCount = snapshotSaveRequiredRef.current
1086
+ ? HUMAN_VERSION_STEP_LIMIT
1087
+ : sessionStepCount() + pendingLocalChangesRef.current.length
1088
+ if (pendingStepCount < HUMAN_VERSION_STEP_LIMIT) return
1089
+ if (saveTimer.current !== null) window.clearTimeout(saveTimer.current)
1090
+ const id = meta?.id
1091
+ if (id === undefined) return
1092
+ saveTimer.current = window.setTimeout(() => {
1093
+ saveTimer.current = null
1094
+ const promise = savePendingUserChanges(id)
1095
+ pendingSavePromiseRef.current = promise
1096
+ void promise.finally(() => {
1097
+ if (pendingSavePromiseRef.current === promise) pendingSavePromiseRef.current = null
1098
+ }).catch(() => { /* status is already shown by savePendingUserChanges */ })
1099
+ }, 0)
1100
+ }
1101
+
1102
+ const flushPendingSave = async (): Promise<void> => {
1103
+ if (saveTimer.current !== null) {
1104
+ window.clearTimeout(saveTimer.current)
1105
+ saveTimer.current = null
1106
+ }
1107
+ if (!dirtyRef.current || meta === null) return
1108
+ if (conflictsRef.current.length > 0) throw new Error('当前存在三方合并冲突,请先提交合并结果或重新加载最新版本')
1109
+ // A save submits at most ten steps. Keep waiting for any follow-up save
1110
+ // started by the single-flight drain so Ctrl/Cmd+S and workbook switching
1111
+ // never leave a tail of pending edits behind.
1112
+ while (dirtyRef.current && conflictsRef.current.length === 0) {
1113
+ if (!snapshotSaveRequiredRef.current && pendingLocalChangesRef.current.length === 0) break
1114
+ if (pendingSavePromiseRef.current !== null) {
1115
+ await pendingSavePromiseRef.current
1116
+ } else if (!savingRef.current) {
1117
+ await savePendingUserChanges(meta.id)
1118
+ } else {
1119
+ return
1120
+ }
1121
+ }
1122
+ if (conflictsRef.current.length > 0) throw new Error('已完成自动合并,但仍有冲突单元格待处理')
1123
+ }
1124
+
1125
+ /** Name the current content as an explicit Version even without new edits (PRD §8). */
1126
+ const saveNamedVersion = async (name: string): Promise<void> => {
1127
+ const id = activeWorkbookIdRef.current
1128
+ if (id === null) return
1129
+ // The canvas is showing a past Version: saving it would forge the user's
1130
+ // history. Leaving preview is one click, and the copy says so.
1131
+ if (preview !== null) {
1132
+ setSaveVersionOpen(false)
1133
+ refuseEdit()
1134
+ return
1135
+ }
1136
+ // D4: an empty name is allowed — "just save this" must not become a naming
1137
+ // chore, so it is auto-named like `6/18 09:41`.
1138
+ const label = name.trim() === '' ? autoVersionName() : name.trim()
1139
+ const snapshotData = viewHandle.current?.save()
1140
+ if (snapshotData === undefined || snapshotData === null || typeof snapshotData !== 'object') {
1141
+ setNotice('无法读取当前工作簿快照,请稍后重试。')
1142
+ return
1143
+ }
1144
+ // A whole-snapshot save carries the *canvas*, so every queued change is
1145
+ // already inside `snapshotData`. Dropping the queue here rather than when
1146
+ // the answer arrives is what makes an edit arriving during the round-trip
1147
+ // distinguishable: the generation moves and the queue refills.
1148
+ const savedGeneration = editGenerationRef.current
1149
+ const queuedBeforeSave = pendingLocalChangesRef.current
1150
+ const queuedSnapshotBeforeSave = snapshotSaveRequiredRef.current
1151
+ pendingLocalChangesRef.current = []
1152
+ snapshotSaveRequiredRef.current = false
1153
+ setSaveVersionPending(true)
1154
+ try {
1155
+ const result = await saveSnapshot(id, snapshotData as object, revisionRef.current, userOpId(id), {
1156
+ summary: label,
1157
+ versionKind: 'named',
1158
+ versionStatus: 'final',
1159
+ })
1160
+ if (result !== null) {
1161
+ // A named save is a deliberate Version boundary on the Host, so the
1162
+ // unretained session ends here as well.
1163
+ setSession(null)
1164
+ adoptStoredSnapshot(id, snapshotData as object, result.revision)
1165
+ setSaveVersionOpen(false)
1166
+ setNotice(`已保存版本「${label}」。`)
1167
+ // An edit made while the request was in flight is not in the Version
1168
+ // that was just stored. It opens the next editing session instead of
1169
+ // being discarded along with the queues.
1170
+ const hasNewerEdits = editGenerationRef.current !== savedGeneration
1171
+ || pendingLocalChangesRef.current.length > 0 || snapshotSaveRequiredRef.current
1172
+ dirtyRef.current = hasNewerEdits
1173
+ if (hasNewerEdits) void savePendingUserChanges(id)
1174
+ else setStatus('saved')
1175
+ await loadHistory(id)
1176
+ }
1177
+ } catch (error) {
1178
+ // The queued changes are still on the canvas, but only a whole-snapshot
1179
+ // save can carry them now that the queue is empty — the same answer the
1180
+ // incremental path gives for a failed structural change.
1181
+ if (mountedRef.current) {
1182
+ pendingLocalChangesRef.current = [...queuedBeforeSave, ...pendingLocalChangesRef.current]
1183
+ snapshotSaveRequiredRef.current = queuedSnapshotBeforeSave || pendingLocalChangesRef.current.length > 0
1184
+ dirtyRef.current = snapshotSaveRequiredRef.current || pendingLocalChangesRef.current.length > 0
1185
+ }
1186
+ setStatus(error instanceof RevisionConflictError ? 'conflict' : 'error')
1187
+ setNotice(error instanceof Error ? error.message : String(error))
1188
+ } finally {
1189
+ if (mountedRef.current) setSaveVersionPending(false)
1190
+ }
1191
+ }
1192
+
1193
+ const persistSnapshotForTarget = (id: string, snapshotData: object, baseRevision: number, opId: string): Promise<SaveResult | null> => {
1194
+ // Import / merge / structural writes all funnel through here. While a past
1195
+ // Version is on screen the workbook on screen is not the workbook being
1196
+ // edited, so none of them may be recorded.
1197
+ if (previewRef.current !== null) {
1198
+ refuseEdit()
1199
+ return Promise.resolve(null)
1200
+ }
1201
+ // Import / merge / structural flush: one auto Step of the editing session.
1202
+ return saveSnapshot(id, snapshotData, baseRevision, opId, { versionKind: 'auto', versionStatus: 'open' })
1203
+ }
1204
+
1205
+ // Univer/browser defaults may interpret Ctrl/Cmd+S as a navigation or
1206
+ // refresh action in the host. Datasheet owns persistence, so consume the
1207
+ // shortcut and flush the queue without remounting or reloading the canvas.
1208
+ flushPendingSaveRef.current = flushPendingSave
1209
+ useEffect(() => {
1210
+ // Window-level: must not stay bound while the Table tab is off screen.
1211
+ if (!visible) return
1212
+ const onSaveShortcut = (event: KeyboardEvent): void => {
1213
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return
1214
+ event.preventDefault()
1215
+ event.stopPropagation()
1216
+ event.stopImmediatePropagation()
1217
+ const flush = flushPendingSaveRef.current
1218
+ if (flush === null) return
1219
+ // Ctrl/⌘+S is its own confirmation: the top menu row already flips to
1220
+ // 已保存, so it does not need a top-right toast on top of that.
1221
+ void flush().catch(error => {
1222
+ if (mountedRef.current) {
1223
+ setStatus('error')
1224
+ setNotice(error instanceof Error ? error.message : String(error))
1225
+ }
1226
+ })
1227
+ }
1228
+ window.addEventListener('keydown', onSaveShortcut, true)
1229
+ return () => window.removeEventListener('keydown', onSaveShortcut, true)
1230
+ }, [visible])
1231
+
1232
+ // Leaving the tab commits whatever is still queued, but it is **not** a
1233
+ // Version boundary (PRD v3.3 D21): the request carries the persisted session
1234
+ // id, so it joins the same unretained Version. Only the idle window and the
1235
+ // Step cap close a Version. The flush stays because the resident workspace can
1236
+ // be released after a long idle stay, and an unsaved edit would go with it.
1237
+ useEffect(() => {
1238
+ if (visible || !dirtyRef.current) return
1239
+ const flush = flushPendingSaveRef.current
1240
+ if (flush === null) return
1241
+ void flush().catch(() => { /* the next visit revalidates and retries */ })
1242
+ }, [visible])
1243
+
1244
+ /**
1245
+ * Apply committed remote operations in Host revision order. Cell writes are
1246
+ * safe to project even when coordinates overlap: after the remote operation
1247
+ * is painted, the user's pending edits are replayed as a local preview and
1248
+ * will be committed after it. Structural/snapshot changes still use merge.
1249
+ */
1250
+ const drainRemoteOperations = async (): Promise<void> => {
1251
+ if (drainingRemoteRef.current || meta === null || !mountedRef.current) return
1252
+ // A preview residency is rendering a past Version, so a remote operation
1253
+ // must not be painted onto it — the queue is kept intact and drains once the
1254
+ // user leaves preview. Read through a ref because the sync interval and the
1255
+ // collaboration stream captured this callback on an earlier render.
1256
+ if (previewRef.current !== null) return
1257
+ drainingRemoteRef.current = true
1258
+ try {
1259
+ while (mountedRef.current && activeWorkbookIdRef.current === meta.id) {
1260
+ for (const revision of [...remoteOperationsRef.current.keys()]) {
1261
+ if (revision <= revisionRef.current) remoteOperationsRef.current.delete(revision)
1262
+ }
1263
+ if (remoteSnapshotPendingRef.current) {
1264
+ if (dirtyRef.current || savingRef.current) break
1265
+ await reloadSnapshot(meta.id, true)
1266
+ continue
1267
+ }
1268
+ const next = remoteOperationsRef.current.get(revisionRef.current + 1)
1269
+ if (next === undefined) {
1270
+ const hasGap = [...remoteOperationsRef.current.keys()].some(revision => revision > revisionRef.current + 1)
1271
+ if (!hasGap) break
1272
+ try {
1273
+ const changes = await fetchChanges(meta.id, revisionRef.current)
1274
+ serverRevisionRef.current = Math.max(serverRevisionRef.current, changes.revision ?? 0)
1275
+ if (changes.requiresSnapshot) {
1276
+ remoteSnapshotPendingRef.current = true
1277
+ continue
1278
+ }
1279
+ for (const operation of changes.operations ?? []) remoteOperationsRef.current.set(operation.revision, operation)
1280
+ continue
1281
+ } catch {
1282
+ break
1283
+ }
1284
+ }
1285
+ if (dirtyRef.current || savingRef.current) {
1286
+ const localChanges = coalesceLocalChanges(pendingLocalChangesRef.current)
1287
+ if (!canApplyRemoteWhileEditing(next, snapshotSaveRequiredRef.current)) {
1288
+ remoteSnapshotPendingRef.current = true
1289
+ break
1290
+ }
1291
+ }
1292
+ // A write this browser made, echoed back over the collaboration stream.
1293
+ // It is already on the canvas and in the baseline, so the only correct
1294
+ // answer is to retire its revision and move on; the `snapshot_replace`
1295
+ // branch below would otherwise re-download a snapshot the canvas is
1296
+ // already showing. Checked before the dirty/saving branch, because the
1297
+ // echo can arrive while the POST that produced it is still in flight.
1298
+ if (ownOpsRef.current.take(next.opId)) {
1299
+ remoteOperationsRef.current.delete(next.revision)
1300
+ setCurrentRevision(next.revision)
1301
+ continue
1302
+ }
1303
+ remoteOperationsRef.current.delete(next.revision)
1304
+ if (next.actor === 'agent') {
1305
+ // Agent writes close a user's open Version on the Host.
1306
+ // Discard the client-side join cursor so the next human edit starts
1307
+ // a fresh logical Version after this remote revision.
1308
+ setSession(null)
1309
+ }
1310
+ if (next.type === 'snapshot_replace') {
1311
+ if (dirtyRef.current || savingRef.current) {
1312
+ remoteOperationsRef.current.set(next.revision, next)
1313
+ remoteSnapshotPendingRef.current = true
1314
+ break
1315
+ }
1316
+ await reloadSnapshot(meta.id, true)
1317
+ continue
1318
+ }
1319
+ const handle = viewHandle.current
1320
+ if (handle === null) {
1321
+ remoteOperationsRef.current.set(next.revision, next)
1322
+ remoteSnapshotPendingRef.current = true
1323
+ break
1324
+ }
1325
+ try {
1326
+ await handle.applyOperation(next)
1327
+ } catch {
1328
+ if (dirtyRef.current || savingRef.current) {
1329
+ remoteOperationsRef.current.set(next.revision, next)
1330
+ remoteSnapshotPendingRef.current = true
1331
+ break
1332
+ }
1333
+ await reloadSnapshot(meta.id, true)
1334
+ continue
1335
+ }
1336
+ if (baselineRef.current !== null && !applyIncrementalOperationsToSnapshot(baselineRef.current, [next])) {
1337
+ if (dirtyRef.current || savingRef.current) {
1338
+ remoteOperationsRef.current.set(next.revision, next)
1339
+ remoteSnapshotPendingRef.current = true
1340
+ break
1341
+ }
1342
+ await reloadSnapshot(meta.id, true)
1343
+ continue
1344
+ }
1345
+ if (dirtyRef.current) {
1346
+ try {
1347
+ await applyLocalChangesToCanvas(coalesceLocalChanges(pendingLocalChangesRef.current), meta.id, revisionRef.current)
1348
+ } catch {
1349
+ remoteSnapshotPendingRef.current = true
1350
+ break
1351
+ }
1352
+ }
1353
+ setCurrentRevision(next.revision)
1354
+ }
1355
+ } finally {
1356
+ drainingRemoteRef.current = false
1357
+ }
1358
+ }
1359
+
1360
+ const switchWorkbook = async (id: string): Promise<void> => {
1361
+ if (id.length === 0 || id === meta?.id || switching) return
1362
+ setSwitching(true)
1363
+ try {
1364
+ await flushPendingSave()
1365
+ let envelope: WorkbookEnvelope
1366
+ envelope = await fetchWorkbook(id)
1367
+ if (envelope.snapshot === undefined) throw new Error('workbook snapshot 缺失')
1368
+ if (!mountedRef.current) return
1369
+ publishDataSheetSelection([])
1370
+ const name = workbookName(id, workbooksRef.current)
1371
+ const nextSnapshot = withWorkbookName(envelope.snapshot, name)
1372
+ // Keep the old workbook mounted until the target has loaded. This makes
1373
+ // switching atomic from the user's perspective and avoids a blank grid
1374
+ // when a target request fails.
1375
+ baselineRef.current = cloneSnapshot(nextSnapshot)
1376
+ setMergeConflicts([])
1377
+ // Leaving the artifact also leaves its preview: the past Version belonged
1378
+ // to the workbook that is being replaced.
1379
+ setPreview(null)
1380
+ setPresence([])
1381
+ pendingLocalChangesRef.current = []
1382
+ // Returning to a workbook continues the unretained session it left open
1383
+ // (PRD v3.3 D22). Only the idle window or the Step cap may close it.
1384
+ seedSession(id)
1385
+ snapshotSaveRequiredRef.current = false
1386
+ remoteOperationsRef.current.clear()
1387
+ remoteSnapshotPendingRef.current = false
1388
+ setSnapshot(nextSnapshot)
1389
+ activeWorkbookIdRef.current = id
1390
+ serverRevisionRef.current = envelope.revision ?? 0
1391
+ setMeta({ id, name })
1392
+ setCurrentRevision(envelope.revision ?? 0)
1393
+ dirtyRef.current = false
1394
+ setNotice(null)
1395
+ setViewKey(key => key + 1)
1396
+ setStatus('saved')
1397
+ } catch (error) {
1398
+ if (mountedRef.current) {
1399
+ setStatus(error instanceof RevisionConflictError || conflictsRef.current.length > 0 ? 'conflict' : 'error')
1400
+ setNotice(error instanceof Error ? error.message : String(error))
1401
+ }
1402
+ } finally {
1403
+ if (mountedRef.current) setSwitching(false)
1404
+ }
1405
+ }
1406
+
1407
+ const switchWorkbookRef = useRef(switchWorkbook)
1408
+ switchWorkbookRef.current = switchWorkbook
1409
+ const applyPendingBridgeFocus = useCallback((): void => {
1410
+ const target = pendingBridgeFocusRef.current
1411
+ if (target === null || switching || target.workbookId !== meta?.id) return
1412
+ if (viewHandle.current?.focusRange(target)) {
1413
+ pendingBridgeFocusRef.current = null
1414
+ bridgeFocusAttemptRef.current = null
1415
+ }
1416
+ }, [meta?.id, switching])
1417
+ const focusDataSheetSelection = useCallback((itemId: string): void => {
1418
+ const target = dataSheetFocusTargetOf(itemId)
1419
+ if (target === undefined) return
1420
+ pendingBridgeFocusRef.current = target
1421
+ bridgeFocusAttemptRef.current = null
1422
+ if (target.workbookId === meta?.id) {
1423
+ applyPendingBridgeFocus()
1424
+ return
1425
+ }
1426
+ if (!switching) {
1427
+ bridgeFocusAttemptRef.current = JSON.stringify(target)
1428
+ void switchWorkbookRef.current(target.workbookId)
1429
+ }
1430
+ }, [applyPendingBridgeFocus, meta?.id, switching])
1431
+ useEffect(() => {
1432
+ const target = pendingBridgeFocusRef.current
1433
+ if (target === null || switching) return
1434
+ if (target.workbookId === meta?.id) applyPendingBridgeFocus()
1435
+ else {
1436
+ const key = JSON.stringify(target)
1437
+ if (bridgeFocusAttemptRef.current !== key) {
1438
+ bridgeFocusAttemptRef.current = key
1439
+ void switchWorkbookRef.current(target.workbookId)
1440
+ }
1441
+ }
1442
+ }, [applyPendingBridgeFocus, meta?.id, switching])
1443
+ useEffect(() => {
1444
+ if (bridge === undefined) return
1445
+ const unregister = registerDataSheetFocusHandler(focusDataSheetSelection)
1446
+ const takeFocus = (): void => {
1447
+ const request = bridge.takeFocus(DATA_SHEET_SURFACE_ID)
1448
+ if (request === null) return
1449
+ for (const itemId of request.itemIds) focusDataSheetSelection(itemId)
1450
+ }
1451
+ const unsubscribe = bridge.subscribe(takeFocus)
1452
+ takeFocus()
1453
+ return () => {
1454
+ unsubscribe()
1455
+ unregister()
1456
+ }
1457
+ }, [bridge, focusDataSheetSelection])
1458
+
1459
+ // Persist a pending user edit before the panel is torn down.
1460
+ useEffect(() => {
1461
+ return () => {
1462
+ if (saveTimer.current !== null) window.clearTimeout(saveTimer.current)
1463
+ if (dirtyRef.current && conflictsRef.current.length === 0) {
1464
+ const id = meta?.id ?? initialWorkbookId
1465
+ void savePendingUserChanges(id).catch(() => { /* teardown cannot show a toast */ })
1466
+ }
1467
+ }
1468
+ }, [initialWorkbookId, meta])
1469
+
1470
+ // Give the browser a last chance to send queued cell steps when the tab is
1471
+ // hidden or closed. `keepalive` is applied at the request layer too;
1472
+ // normal saves are threshold-based and single-flight.
1473
+ useEffect(() => {
1474
+ const onPageHide = (): void => {
1475
+ if (!dirtyRef.current || conflictsRef.current.length > 0 || savingRef.current) return
1476
+ void savePendingUserChanges(meta?.id ?? initialWorkbookId).catch(() => { /* page is closing */ })
1477
+ }
1478
+ window.addEventListener('pagehide', onPageHide)
1479
+ return () => window.removeEventListener('pagehide', onPageHide)
1480
+ }, [initialWorkbookId, meta])
1481
+
1482
+ // Import is initiated by the Univer File menu and committed through the
1483
+ // same optimistic snapshot endpoint as normal user edits.
1484
+ useEffect(() => {
1485
+ const onImport = (event: Event): void => {
1486
+ const detail = (event as CustomEvent<{ snapshot?: object; filename?: string }>).detail
1487
+ if (detail?.snapshot === undefined || meta === null) return
1488
+ savingRef.current = true
1489
+ setStatus('saving')
1490
+ const importedSnapshot = withWorkbookName(detail.snapshot, meta.name)
1491
+ pendingLocalChangesRef.current = []
1492
+ setSession(null)
1493
+ snapshotSaveRequiredRef.current = false
1494
+ void persistSnapshotForTarget(meta.id, importedSnapshot, revisionRef.current, userOpId(meta.id))
1495
+ .then(result => {
1496
+ if (result === null || !mountedRef.current) return
1497
+ baselineRef.current = cloneSnapshot(importedSnapshot)
1498
+ setMergeConflicts([])
1499
+ pendingLocalChangesRef.current = []
1500
+ setSession(null)
1501
+ snapshotSaveRequiredRef.current = false
1502
+ dirtyRef.current = false
1503
+ setCurrentRevision(result.revision)
1504
+ setSnapshot(importedSnapshot)
1505
+ setViewKey(key => key + 1)
1506
+ setStatus('saved')
1507
+ setNotice(`已导入 ${detail.filename ?? 'Excel 文件'}`)
1508
+ })
1509
+ .catch(async error => {
1510
+ if (mountedRef.current) {
1511
+ if (error instanceof RevisionConflictError) {
1512
+ try {
1513
+ await mergeWithLatestRemote(meta.id, importedSnapshot)
1514
+ } catch (mergeError) {
1515
+ setStatus('conflict')
1516
+ setNotice(mergeError instanceof Error ? mergeError.message : String(mergeError))
1517
+ }
1518
+ } else {
1519
+ setStatus('error')
1520
+ setNotice(error instanceof Error ? error.message : String(error))
1521
+ }
1522
+ }
1523
+ })
1524
+ .finally(() => { savingRef.current = false })
1525
+ }
1526
+ const onImportError = (event: Event): void => {
1527
+ const message = (event as CustomEvent<{ message?: string }>).detail?.message ?? 'Excel 导入失败'
1528
+ setStatus('error')
1529
+ setNotice(message)
1530
+ }
1531
+ window.addEventListener('data-sheet:import', onImport)
1532
+ window.addEventListener('data-sheet:import-error', onImportError)
1533
+ return () => {
1534
+ window.removeEventListener('data-sheet:import', onImport)
1535
+ window.removeEventListener('data-sheet:import-error', onImportError)
1536
+ }
1537
+ }, [meta])
1538
+
1539
+ // Self-hosted collaboration session. The server uses SSE as a lightweight
1540
+ // transport, while this client keeps the Pro-inspired session states:
1541
+ // connected, pending/awaiting (represented by the save queue), fetch-miss,
1542
+ // offline and conflict. Polling remains as a compatibility fallback.
1543
+ useEffect(() => {
1544
+ if (!open || !hasSnapshot || meta === null) return
1545
+ let cancelled = false
1546
+ let source: EventSource | null = null
1547
+ let reconnectTimer: number | null = null
1548
+
1549
+ const enqueue = (changes: ChangesEnvelope): void => {
1550
+ if (cancelled) return
1551
+ serverRevisionRef.current = Math.max(serverRevisionRef.current, changes.revision ?? 0)
1552
+ if (changes.requiresSnapshot) remoteSnapshotPendingRef.current = true
1553
+ for (const operation of changes.operations ?? []) remoteOperationsRef.current.set(operation.revision, operation)
1554
+ void drainRemoteOperations()
1555
+ }
1556
+
1557
+ const connect = (): void => {
1558
+ if (cancelled) return
1559
+ const after = serverRevisionRef.current
1560
+ source = new EventSource(dataSheetClientPath(collaborationStreamPath(meta.id, after)))
1561
+ source.addEventListener('ready', event => {
1562
+ try {
1563
+ const envelope = JSON.parse((event as MessageEvent<string>).data) as CollaborationReadyEnvelope
1564
+ enqueue(envelope)
1565
+ } catch { /* the polling fallback repairs malformed events */ }
1566
+ })
1567
+ source.addEventListener('operation', event => {
1568
+ try {
1569
+ const value = JSON.parse((event as MessageEvent<string>).data) as { operation?: WorkbookOperation }
1570
+ if (value.operation !== undefined) enqueue({ revision: value.operation.revision, operations: [value.operation] })
1571
+ } catch {
1572
+ // Ignore malformed transient notifications; the next poll repairs the cursor.
1573
+ }
1574
+ })
1575
+ source.addEventListener('presence', event => {
1576
+ try {
1577
+ const value = JSON.parse((event as MessageEvent<string>).data) as { presence?: WorkbookPresence }
1578
+ const incoming = value.presence
1579
+ if (incoming === undefined || incoming.sessionId === dataSheetSessionId()) return
1580
+ setPresence(current => [...current.filter(item => item.sessionId !== incoming.sessionId), incoming].slice(-24))
1581
+ } catch { /* presence is best-effort */ }
1582
+ })
1583
+ source.onerror = () => {
1584
+ source?.close()
1585
+ source = null
1586
+ if (!cancelled && reconnectTimer === null) {
1587
+ reconnectTimer = window.setTimeout(() => {
1588
+ reconnectTimer = null
1589
+ connect()
1590
+ }, 2000)
1591
+ }
1592
+ }
1593
+ }
1594
+
1595
+ const pollFallback = async (): Promise<void> => {
1596
+ if (cancelled || source?.readyState === EventSource.OPEN) return
1597
+ try {
1598
+ enqueue(await fetchChanges(meta.id, revisionRef.current))
1599
+ } catch { /* the next poll/reconnect retries */ }
1600
+ }
1601
+ connect()
1602
+ void pollFallback()
1603
+ const timer = window.setInterval(() => { void pollFallback() }, SYNC_INTERVAL_MS)
1604
+ const presenceExpiry = window.setInterval(() => {
1605
+ const cutoff = Date.now() - 30_000
1606
+ setPresence(current => current.filter(item => {
1607
+ const timestamp = Date.parse(item.timestamp)
1608
+ return !Number.isFinite(timestamp) || timestamp >= cutoff
1609
+ }))
1610
+ }, 10_000)
1611
+ return () => {
1612
+ cancelled = true
1613
+ window.clearInterval(timer)
1614
+ window.clearInterval(presenceExpiry)
1615
+ if (reconnectTimer !== null) window.clearTimeout(reconnectTimer)
1616
+ source?.close()
1617
+ source = null
1618
+ }
1619
+ // `hasSnapshot` (not `snapshot`): reconnecting the stream on every
1620
+ // revalidation would drop and rebuild the session on each load.
1621
+ }, [hasSnapshot, meta, open])
1622
+
1623
+ const reloadLatest = async (): Promise<void> => {
1624
+ if (meta === null) return
1625
+ try {
1626
+ setNotice(null)
1627
+ await reloadSnapshot(meta.id, true)
1628
+ dirtyRef.current = false
1629
+ setStatus('saved')
1630
+ } catch (error) {
1631
+ setStatus('error')
1632
+ setNotice(error instanceof Error ? error.message : String(error))
1633
+ }
1634
+ }
1635
+
1636
+ const refreshWorkbookCatalog = async (): Promise<WorkbookSummary[]> => {
1637
+ const choices = (await fetchWorkbooks()).workbooks ?? []
1638
+ workbooksRef.current = choices
1639
+ if (mountedRef.current) setWorkbooks(choices)
1640
+ return choices
1641
+ }
1642
+
1643
+ const openCreateWorkbookDialog = (): void => {
1644
+ if (workbookActionPending !== null) return
1645
+ setWorkbookDialog({ kind: 'create', draft: `工作簿 ${workbooksRef.current.length + 1}` })
1646
+ }
1647
+
1648
+ const openRenameWorkbookDialog = (id: string): void => {
1649
+ const target = workbooksRef.current.find(workbook => workbook.id === id)
1650
+ if (target === undefined || workbookActionPending !== null) return
1651
+ setWorkbookDialog({ kind: 'rename', id, draft: target.name })
1652
+ }
1653
+
1654
+ const openDeleteWorkbookDialog = (id: string): void => {
1655
+ const target = workbooksRef.current.find(workbook => workbook.id === id)
1656
+ if (target === undefined || id === meta?.id || workbookActionPending !== null || deletingWorkbookId !== null) return
1657
+ setWorkbookDialog({ kind: 'delete', id })
1658
+ }
1659
+
1660
+ const submitWorkbookDialog = async (): Promise<void> => {
1661
+ const dialog = workbookDialog
1662
+ if (dialog === null || workbookActionPending !== null) return
1663
+ const target = dialog.kind === 'create' ? undefined : workbooksRef.current.find(workbook => workbook.id === dialog.id)
1664
+ if (dialog.kind !== 'create' && target === undefined) {
1665
+ setWorkbookDialog(null)
1666
+ return
1667
+ }
1668
+ if (dialog.kind !== 'delete' && dialog.draft.trim() === '') {
1669
+ setNotice('工作簿名称不能为空')
1670
+ return
1671
+ }
1672
+ setWorkbookActionPending(dialog.kind)
1673
+ if (dialog.kind === 'delete') setDeletingWorkbookId(dialog.id)
1674
+ try {
1675
+ if (dialog.kind === 'create') {
1676
+ const name = dialog.draft.trim()
1677
+ const result = await createWorkbook(name)
1678
+ const choices = await refreshWorkbookCatalog()
1679
+ const createdId = result.workbook?.id ?? choices.find(workbook => workbook.name === name)?.id
1680
+ setWorkbookDialog(null)
1681
+ if (createdId !== undefined) await switchWorkbook(createdId)
1682
+ setNotice(`已创建工作簿「${name}」`)
1683
+ } else if (dialog.kind === 'rename' && target !== undefined) {
1684
+ const name = dialog.draft.trim()
1685
+ if (name === target.name) {
1686
+ setWorkbookDialog(null)
1687
+ return
1688
+ }
1689
+ const result = await renameWorkbookRequest(dialog.id, name)
1690
+ const nextName = result.workbook?.name ?? name
1691
+ const choices = workbooksRef.current.map(workbook => workbook.id === dialog.id ? { ...workbook, name: nextName, updatedAt: new Date().toISOString() } : workbook)
1692
+ workbooksRef.current = choices
1693
+ if (mountedRef.current) {
1694
+ setWorkbooks(choices)
1695
+ if (meta?.id === dialog.id) {
1696
+ setMeta(current => current === null ? current : { ...current, name: nextName })
1697
+ setSnapshot(current => current === null ? current : withWorkbookName(current, nextName))
1698
+ }
1699
+ setNotice(`已重命名为「${nextName}」`)
1700
+ }
1701
+ setWorkbookDialog(null)
1702
+ } else if (dialog.kind === 'delete' && target !== undefined) {
1703
+ await deleteWorkbookRequest(dialog.id)
1704
+ const choices = await refreshWorkbookCatalog()
1705
+ if (mountedRef.current) {
1706
+ setWorkbooks(choices)
1707
+ setNotice(`已删除工作簿「${target.name}」`)
1708
+ }
1709
+ setWorkbookDialog(null)
1710
+ }
1711
+ } catch (error) {
1712
+ setStatus('error')
1713
+ setNotice(error instanceof Error ? error.message : String(error))
1714
+ } finally {
1715
+ if (mountedRef.current) {
1716
+ setWorkbookActionPending(null)
1717
+ setDeletingWorkbookId(null)
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ return (
1723
+ <div className={`${styles.panel} ${standalone ? styles.standalone : ''}`}>
1724
+ <WorkspaceTopBar>
1725
+ <ObjectPicker
1726
+ label="工作簿"
1727
+ name={meta?.name ?? ''}
1728
+ placeholder="选择工作簿"
1729
+ items={workbooks.map(workbook => ({ id: workbook.id, name: workbook.name, meta: `${workbook.sheets.length} 个 sheet` }))}
1730
+ currentId={meta?.id}
1731
+ disabled={switching && workbooks.length === 0}
1732
+ emptyLabel="暂无工作簿"
1733
+ createLabel="新建工作簿"
1734
+ onCreate={openCreateWorkbookDialog}
1735
+ onSelect={id => { void switchWorkbook(id) }}
1736
+ onRename={openRenameWorkbookDialog}
1737
+ onDelete={openDeleteWorkbookDialog}
1738
+ />
1739
+ <TopBarDivider />
1740
+ <TopBarGroup>
1741
+ {meta !== null && !standalone && (
1742
+ <Button
1743
+ variant="outline"
1744
+ size="sm"
1745
+ title="在浏览器窗口里打开当前工作簿"
1746
+ onClick={() => void fullPage.open({ src: standaloneWorkbookPath(meta.id), title: meta.name || meta.id })}
1747
+ >
1748
+ <ExternalLink size={14} aria-hidden="true" />整页打开
1749
+ </Button>
1750
+ )}
1751
+ <Button
1752
+ variant="outline"
1753
+ size="sm"
1754
+ disabled={meta === null}
1755
+ onClick={() => { setHistoryOpen(true); if (meta !== null) void loadHistory(meta.id) }}
1756
+ >
1757
+ <History size={14} aria-hidden="true" />版本管理
1758
+ </Button>
1759
+ </TopBarGroup>
1760
+ <TopBarDivider />
1761
+ <TopBarGroup>
1762
+ {/* The menu and the project file chooser behind it live together in
1763
+ their own module; the panel only declares where it sits. */}
1764
+ <ProjectImportMenu disabled={meta === null} />
1765
+ <ToolbarMenu
1766
+ label="导出"
1767
+ icon={<ArrowDownToLine size={14} aria-hidden="true" />}
1768
+ disabled={meta === null}
1769
+ groups={[{
1770
+ items: [
1771
+ { id: 'xlsx', label: '导出 Excel', hint: '保留工作簿结构与样式', icon: <FileSpreadsheet size={15} aria-hidden="true" />, onSelect: () => { window.dispatchEvent(new CustomEvent('data-sheet:request-export-xlsx')) } },
1772
+ { id: 'csv', label: '导出 CSV', hint: '导出当前工作表数据', icon: <FileText size={15} aria-hidden="true" />, onSelect: () => { window.dispatchEvent(new CustomEvent('data-sheet:request-export-csv')) } },
1773
+ { id: 'json', label: '导出 JSON', hint: '完整工作簿快照', icon: <FileJson size={15} aria-hidden="true" />, onSelect: () => { window.dispatchEvent(new CustomEvent('data-sheet:request-export-json')) } },
1774
+ ],
1775
+ }]}
1776
+ />
1777
+ </TopBarGroup>
1778
+ {switching && <span className={styles.switchingLabel}>切换中…</span>}
1779
+ <SaveStatus state={status} onRetry={() => { void reloadLatest() }} onReload={() => { void reloadLatest() }} />
1780
+ </WorkspaceTopBar>
1781
+ <Dialog open={workbookDialog !== null} onOpenChange={open => { if (!open && workbookActionPending === null) setWorkbookDialog(null) }}>
1782
+ <DialogContent>
1783
+ <DialogHeader>
1784
+ <DialogTitle>{workbookDialog?.kind === 'create' ? '新建工作簿' : workbookDialog?.kind === 'rename' ? '重命名工作簿' : '删除工作簿'}</DialogTitle>
1785
+ <DialogDescription>{workbookDialog?.kind === 'delete' ? '此操作会同时删除编辑历史,且无法恢复。' : '名称会显示在顶部工作簿切换器中。'}</DialogDescription>
1786
+ </DialogHeader>
1787
+ {workbookDialog?.kind === 'delete' ? <p className={styles.dialogMessage}>确定删除工作簿「{workbooksRef.current.find(workbook => workbook.id === workbookDialog.id)?.name ?? ''}」吗?</p> : (workbookDialog?.kind === 'create' || workbookDialog?.kind === 'rename') && <label className={styles.dialogField}><span>工作簿名称</span><Input autoFocus value={workbookDialog.draft} onChange={event => setWorkbookDialog(current => current !== null && current.kind !== 'delete' ? { ...current, draft: event.target.value } : current)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); void submitWorkbookDialog() } if (event.key === 'Escape' && workbookActionPending === null) setWorkbookDialog(null) }} /></label>}
1788
+ <DialogFooter>
1789
+ <Button variant="outline" size="sm" disabled={workbookActionPending !== null} onClick={() => setWorkbookDialog(null)}>取消</Button>
1790
+ <Button variant={workbookDialog?.kind === 'delete' ? 'destructive' : 'default'} size="sm" disabled={workbookActionPending !== null || (workbookDialog !== null && workbookDialog.kind !== 'delete' && workbookDialog.draft.trim() === '')} onClick={() => { void submitWorkbookDialog() }}>{workbookActionPending !== null ? '处理中…' : workbookDialog?.kind === 'delete' ? '确认删除' : workbookDialog?.kind === 'create' ? '创建工作簿' : '保存名称'}</Button>
1791
+ </DialogFooter>
1792
+ </DialogContent>
1793
+ </Dialog>
1794
+ <div className={styles.body}>
1795
+ {toast !== null && <div className={styles.toast} role="status">{toast}</div>}
1796
+ {notice !== null && (
1797
+ // A banner, not an overlay: it sits in the flex column above the
1798
+ // canvas and can be dismissed (GUI feedback 2026-09-19 — the old
1799
+ // absolute notice covered the Univer toolbar and had no close button).
1800
+ <div className={styles.notice} role="status">
1801
+ <span className={styles.noticeMessage}>{notice}</span>
1802
+ <button
1803
+ type="button"
1804
+ className={styles.noticeClose}
1805
+ aria-label="关闭提示"
1806
+ title="关闭"
1807
+ onClick={() => setNotice(null)}
1808
+ >
1809
+ <X size={13} aria-hidden="true" />
1810
+ </button>
1811
+ </div>
1812
+ )}
1813
+ {conflicts.length > 0 && (
1814
+ <div className={styles.conflictPanel}>
1815
+ <div className={styles.conflictText}>
1816
+ <strong>三方合并待处理</strong>
1817
+ <span>冲突单元格当前保留本地值并已高亮,提交前可以继续修改。</span>
1818
+ <small>{conflicts.slice(0, 10).map(conflict => conflict.address ?? conflict.description).join('、')}{conflicts.length > 10 ? ` 等 ${conflicts.length} 项` : ''}</small>
1819
+ </div>
1820
+ <div className={styles.conflictActions}>
1821
+ <button type="button" className={styles.actionButton} onClick={() => { void commitMergedSnapshot() }}>提交合并结果</button>
1822
+ <button type="button" className={styles.actionButton} onClick={() => { void reloadLatest() }}>放弃本地修改</button>
1823
+ </div>
1824
+ </div>
1825
+ )}
1826
+ <VersionChrome
1827
+ open={historyOpen}
1828
+ onOpenChange={next => {
1829
+ setHistoryOpen(next)
1830
+ if (!next) {
1831
+ // Steps collapse for the next visit — except the current Version's,
1832
+ // which is the one being produced and reopens expanded (item 6).
1833
+ setExpandedHistoryVersions(headRevision === undefined ? new Set() : new Set([headRevision]))
1834
+ }
1835
+ }}
1836
+ artifactName={meta?.name ?? '工作簿'}
1837
+ versionCount={namedVersionCount}
1838
+ headLabel={headLabel}
1839
+ restorableId={headVersion?.versionId}
1840
+ loading={historyLoading}
1841
+ groups={versionGroups}
1842
+ actions={versionActions}
1843
+ save={{
1844
+ open: saveVersionOpen,
1845
+ pending: saveVersionPending,
1846
+ disabled: meta === null,
1847
+ onOpenChange: setSaveVersionOpen,
1848
+ onConfirm: name => { void saveNamedVersion(name) },
1849
+ }}
1850
+ restore={{
1851
+ request: restoreRequest,
1852
+ pending: restorePending,
1853
+ onOpenChange: next => { if (!next) setRestoreRequest(null) },
1854
+ onConfirm: () => {
1855
+ const request = restoreRequest
1856
+ if (request === null) return
1857
+ setRestorePending(true)
1858
+ void restoreHistory(request).finally(() => { setRestorePending(false); setRestoreRequest(null) })
1859
+ },
1860
+ }}
1861
+ />
1862
+ {!open || snapshot === null ? (
1863
+ <div className={styles.hint}>
1864
+ {!open && <p>点击右上角「Table」展开</p>}
1865
+ {open && snapshot === null && <p>{workbooks.length === 0 ? '暂无工作簿,请先创建一个工作簿。' : '请选择要打开的工作簿。'}</p>}
1866
+ </div>
1867
+ ) : (
1868
+ <>
1869
+ {preview !== null && (
1870
+ <div className={styles.previewBar}>
1871
+ <VersionInspectBar
1872
+ // `shown` is the Version on the canvas — the row that was
1873
+ // clicked — and `against` is what it is measured with, the
1874
+ // current content by default. The bar renders them in that
1875
+ // order, so the left picker is always what you are looking at.
1876
+ shownId={preview.versionId}
1877
+ {...(preview.step === null ? {} : { shownStep: preview.step })}
1878
+ againstId={inspectBaselineId}
1879
+ {...(inspectBaselineStep === undefined ? {} : { againstStep: inspectBaselineStep })}
1880
+ options={previewOptions}
1881
+ // Both pickers offer the same two levels: a Step carries the
1882
+ // revision it produced, so "compare against the state after
1883
+ // Step 3" is a measurement the diff can make. `''` is the live
1884
+ // content, which has no id.
1885
+ againstOptions={[
1886
+ { id: '', label: CURRENT_VERSION_LABEL },
1887
+ ...previewOptions,
1888
+ ]}
1889
+ onShownChange={(versionId, step) => { void previewVersion(versionId, step) }}
1890
+ onAgainstChange={(baselineId, baselineStep) => {
1891
+ setInspectBaselineId(baselineId)
1892
+ setInspectBaselineStep(baselineStep)
1893
+ // Re-measure the same Version against the new side; a Step
1894
+ // cursor belongs to a Step diff, so it resets.
1895
+ void previewVersion(preview.versionId, undefined, baselineId, baselineStep)
1896
+ }}
1897
+ summary={previewSummary}
1898
+ loading={previewLoading}
1899
+ onExit={exitPreview}
1900
+ />
1901
+ </div>
1902
+ )}
1903
+ <WorkbookCanvas
1904
+ // The residency only rebuilds when inspect starts, ends, or a swap
1905
+ // failed. Switching the inspected Version keeps the Univer
1906
+ // instance and swaps the unit, so the grid does not flash.
1907
+ key={preview === null ? `${viewKey}:live` : `${viewKey}:inspect-${inspectRemount}`}
1908
+ workbookData={preview === null ? snapshot : preview.snapshot}
1909
+ readOnly={preview !== null}
1910
+ decoration={previewDecoration}
1911
+ onDirty={scheduleSave}
1912
+ onPresence={schedulePresence}
1913
+ onSelectionChange={publishDataSheetSelection}
1914
+ onReady={applyPendingBridgeFocus}
1915
+ ref={handle => { viewHandle.current = handle }}
1916
+ />
1917
+ </>
1918
+ )}
1919
+ </div>
1920
+ {/* 开不了新窗口时那一句。挂在组件树里只是为了有个宿主 —— 它由 Portal 落到 body,
1921
+ z-index 高过抽屉与对话框(2026-10-05 撤掉应用内全屏页之后新增)。 */}
1922
+ {fullPage.notice}
1923
+ </div>
1924
+ )
1925
+ }
1926
+
1927
+ /** Right-column adapter: the DSH layout owns the open/close state. */
1928
+ export function SheetPanel(_: PanelProps) {
1929
+ return <WorkbookWorkspace open={useSheetOpen()} />
1930
+ }
1931
+
1932
+ function workbookName(id: string, workbooks: WorkbookSummary[]): string {
1933
+ return workbooks.find(workbook => workbook.id === id)?.name ?? id
1934
+ }