From ff9a779a6ce1d8e1fa6a3b3daeb41d5d6490dd47 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 26 May 2026 01:23:27 +0800 Subject: [PATCH] refactor: split document session runtime --- ...ime-module-maintainability-checklist-v1.md | 8 +- .../document-editor-adapter-runtime.js | 1350 +--------------- .../browser/document-session-runtime.js | 1362 +++++++++++++++++ rust/crates/mnote-web/src/routes/mod.rs | 5 + rust/crates/mnote-web/src/routes/web_shell.rs | 110 +- 5 files changed, 1465 insertions(+), 1370 deletions(-) create mode 100644 rust/crates/mnote-web/browser/document-session-runtime.js diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index 30d7d693..885b47e9 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -124,13 +124,13 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tests::mno - [x] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。 - [x] B4. `document-mindmap-host-runtime.js`:primary mindmap object shell、mindmap resource tab mount/unmount。 - [x] B5. `document-slash-position-runtime.js`:slash menu active root、positioning、mutation observer。 -- [ ] B6. entrypoint 只负责读取 bootstrap、加载 runtime、装配各子模块。 +- [x] B6. entrypoint 只负责读取 bootstrap、加载 runtime、装配各子模块。 验收标准: -- [ ] 每个新 JS 文件少于 1,500 行。 -- [ ] entrypoint 少于 900 行。 -- [ ] `document_editor_adapter` 相关测试不再依赖在 Rust raw string 中字面搜索。 +- [x] 每个新 JS 文件少于 1,500 行。 +- [x] entrypoint 少于 900 行。 +- [x] `document_editor_adapter` 相关测试不再依赖在 Rust raw string 中字面搜索。 验收命令: diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index 0118aeb8..ec069da1 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -1,5 +1,6 @@ import { createMindmapHostRuntime } from './document-mindmap-host-runtime.js'; import { createResourceTabRuntime } from './document-resource-tab-runtime.js'; +import { createDocumentSessionRuntime } from './document-session-runtime.js'; import { installGlobalSlashMenuPositioning, markIntendedSlashRoot, @@ -19,14 +20,8 @@ import { } from './document-pane-runtime.js'; import { conflictDetectionKeyBelongsToSession, - conflictDetectionKeyFromBody, - editorDocumentFromTiptapDocument, - flattenText, hydrateMindmapAttrsFromDom, - legacyBlocksFromEditorDocument, legacyInlineContentToTiptap, - pageBodyTiptapDocument, - revisionFromConflictKey, toTiptapDocument, } from './document-tiptap-conversion-runtime.js'; @@ -168,8 +163,6 @@ import { } }; - const documentSessionRegistry = new Map(); - const localFolderEventRegistry = new Map(); const paneViewRegistry = new Map(); const currentWebShellWorkspaceId = () => { try { @@ -188,10 +181,6 @@ import { }; let nextViewId = 1; - const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; - const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; - const SESSION_RELEASE_DELAY_MS = 1200; - const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1'; const mindmapHost = createMindmapHostRuntime({ loadRuntime, paneViewRegistry, @@ -206,1317 +195,34 @@ import { if (resourceTabs) resourceTabs.syncResourceSessionTabGuards(session); }; - const ensureLocalFolderSelfChangeSuppressions = () => { - const now = Date.now(); - const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map - ? window.__mnoteLocalFolderSelfChangeSuppressions - : new Map(); - window.__mnoteLocalFolderSelfChangeSuppressions = map; - try { - const raw = window.sessionStorage ? window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY) : ''; - const parsed = raw ? JSON.parse(raw) : null; - if (parsed && typeof parsed === 'object') { - Object.entries(parsed).forEach(([documentId, expiresAt]) => { - const doc = String(documentId || '').trim(); - const expiry = Number(expiresAt || 0); - if (doc && Number.isFinite(expiry) && expiry > now) { - map.set(doc, expiry); - } else if (doc) { - map.delete(doc); - delete parsed[documentId]; - } - }); - if (window.sessionStorage) window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(parsed)); - } - } catch (_) {} - return map; - }; - - const parseLocalFolderEventPayload = (event) => { - try { - return JSON.parse(String(event?.data || '{}')); - } catch (_) { - return null; - } - }; - - const shouldSuppressLocalFolderSelfChange = (documentId, eventKind) => { - const doc = String(documentId || '').trim(); - if (!doc) return false; - const kind = String(eventKind || ''); - const suppressibleSelfWrite = kind.includes('Create') - || kind.includes('Metadata') - || kind.includes('Modify(Data') - || kind.includes('Modify(Any') - || kind.includes('Modify(Name'); - if (!suppressibleSelfWrite) return false; - const suppressions = ensureLocalFolderSelfChangeSuppressions(); - if (!suppressions || typeof suppressions.get !== 'function') return false; - const expiresAt = Number(suppressions.get(doc) || 0); - if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false; - if (Date.now() > expiresAt) { - if (typeof suppressions.delete === 'function') suppressions.delete(doc); - return false; - } - return true; - }; - - const markLocalFolderSelfChangeSuppression = (session, ttlMs = 5000) => { - if (!session || session.sourceKind !== 'local_folder') return; - const doc = String(session.documentId || '').trim(); - if (!doc) return; - const suppressions = ensureLocalFolderSelfChangeSuppressions(); - if (!suppressions || typeof suppressions.set !== 'function') return; - const expiresAt = Date.now() + Math.max(1000, Number(ttlMs) || 5000); - suppressions.set(doc, expiresAt); - try { - if (!window.sessionStorage) return; - const raw = window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY); - const parsed = raw ? JSON.parse(raw) : {}; - const next = parsed && typeof parsed === 'object' ? parsed : {}; - next[doc] = expiresAt; - window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(next)); - } catch (_) {} - }; - - const normalizeSessionSourceKind = (bootstrap) => { - const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : ''; - return value || 'convex_workspace'; - }; - - const buildDocumentSessionKey = (bootstrap) => { - const sourceKind = normalizeSessionSourceKind(bootstrap); - const scope = sourceKind === 'local_folder' - ? String(bootstrap?.rootUri || '').trim() - : String(bootstrap?.workspaceId || '').trim(); - return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`; - }; - - const sessionViews = (session) => Array.from(session.views.values()); - - const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`; - - const detachSessionFromLocalFolderChannel = (session) => { - const channel = session.localFolderChannel; - if (!channel) return; - channel.sessions.delete(session.key); - if (channel.sessions.size === 0) { - try { - channel.eventSource.close(); - } catch (_) { - // noop - } - localFolderEventRegistry.delete(channel.key); - } - session.localFolderChannel = null; - }; - - const releaseDocumentSession = (session) => { - if (session.saveTimer) { - window.clearTimeout(session.saveTimer); - session.saveTimer = 0; - } - if (session.externalRefreshTimer) { - window.clearTimeout(session.externalRefreshTimer); - session.externalRefreshTimer = 0; - } - if (session.releaseTimer) { - window.clearTimeout(session.releaseTimer); - session.releaseTimer = 0; - } - detachSessionFromLocalFolderChannel(session); - if (documentSessionRegistry.get(session.key) === session) { - documentSessionRegistry.delete(session.key); - } - }; - - const scheduleDocumentSessionRelease = (session) => { - if (session.views.size > 0) return; - if (session.releaseTimer) window.clearTimeout(session.releaseTimer); - session.releaseTimer = window.setTimeout(() => { - session.releaseTimer = 0; - if (session.views.size === 0) { - releaseDocumentSession(session); - } - }, SESSION_RELEASE_DELAY_MS); - }; - - const cancelDocumentSessionRelease = (session) => { - if (!session.releaseTimer) return; - window.clearTimeout(session.releaseTimer); - session.releaseTimer = 0; - }; - - window.__mnoteDebugDocumentSessions = { - snapshot: () => ({ - sessionCount: documentSessionRegistry.size, - sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({ - key, - documentId: session.documentId, - sourceKind: session.sourceKind, - rootUri: session.rootUri, - workspaceId: session.workspaceId, - viewCount: session.views.size, - status: session.status, - conflictDetectionKey: session.conflictDetectionKey || '', - lastExternalConflictDetectionKey: session.lastExternalConflictDetectionKey || '', - lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null, - dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), - })), - localFolderChannelCount: localFolderEventRegistry.size, - localFolderRoots: Array.from(localFolderEventRegistry.keys()), - }), - }; - - const currentEditorText = (view) => { - const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); - return editor?.textContent || ''; - }; - - const findScrollableEditorContainer = (view) => { - let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); - while (current instanceof HTMLElement) { - if (current.scrollHeight > current.clientHeight + 8) { - return current; - } - current = current.parentElement; - } - return null; - }; - - const preserveViewScrollPosition = (view, operation) => { - const scrollable = findScrollableEditorContainer(view); - const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null; - const viewportTop = window.scrollY; - const viewportLeft = window.scrollX; - operation(); - const restore = () => { - const current = findScrollableEditorContainer(view); - if (current instanceof HTMLElement && scrollTop != null) { - current.scrollTop = scrollTop; - } - if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) { - window.scrollTo({ - top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY, - left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX, - behavior: 'auto', - }); - } - }; - window.requestAnimationFrame(() => { - restore(); - window.setTimeout(restore, 0); - window.setTimeout(restore, 80); - window.setTimeout(restore, 180); - window.setTimeout(restore, 320); - }); - }; - - const dispatchRuntimeCommand = (view, payload, source) => { - view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(COMMAND_EVENT, { - bubbles: true, - detail: { - protocol: BRIDGE_PROTOCOL, - runtime: 'mnote-leptos-tiptap-spike', - version: '1.1.0', - source: source || 'mnote-web-document-session', - event: COMMAND_EVENT, - payload, - }, - })); - }; - - const clearEmbeddedLocalDraft = (runtimeDescriptor) => { - if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return; - try { - const storage = window.localStorage; - const base = 'mnote.leptos-tiptap-spike.document'; - const keys = [ - `${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`, - `${base}:${runtimeDescriptor.bootstrap.documentId}`, - ]; - for (const key of keys) storage.removeItem(key); - } catch (error) { - console.warn('mnote local folder 草稿清理失败', error); - } - }; - - const setSessionStatus = (session, status, message) => { - session.status = status; - session.error = message || null; - sessionViews(session).forEach((view) => { - setStatus(view.runtimeDescriptor, status, message); - }); - }; - - const dispatchSessionContentToView = (session, view, source) => { - view.suppressedSerialized = session.currentSerialized; - view.lastKnownSerialized = session.currentSerialized; - preserveViewScrollPosition(view, () => { - dispatchRuntimeCommand(view, { - command: 'replaceContent', - documentId: session.documentId, - workspaceId: session.workspaceId, - title: session.title, - content: session.currentTiptapDocument, - revision: session.revision, - conflictDetectionKey: session.conflictDetectionKey, - readOnly: session.readOnly, - editable: !session.readOnly, - }, source || 'mnote-web-document-session-sync'); - }); - }; - - const dispatchSessionMetaToView = (session, view, source) => { - dispatchRuntimeCommand(view, { - command: 'replaceContent', - documentId: session.documentId, - workspaceId: session.workspaceId, - title: session.title, - revision: session.revision, - conflictDetectionKey: session.conflictDetectionKey, - readOnly: session.readOnly, - editable: !session.readOnly, - }, source || 'mnote-web-document-session-meta'); - }; - - const syncSessionMetaToViews = (session) => { - sessionViews(session).forEach((view) => { - if (view.mountId != null) dispatchSessionMetaToView(session, view); - }); - }; - - const broadcastSessionContent = (session, sourceView) => { - sessionViews(session).forEach((view) => { - if (sourceView && view.id === sourceView.id) return; - if (view.mountId == null) return; - dispatchSessionContentToView(session, view); - }); - }; - - const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); - - const sessionPlainText = (session) => { - const liveText = sessionViews(session) - .map((view) => normalizePlainText(currentEditorText(view))) - .find((text) => text); - if (liveText) return liveText; - return normalizePlainText(flattenText(session.currentTiptapDocument)); - }; - - const sessionHasRecentExternalSignal = (session) => ( - session.sourceKind === 'local_folder' - && Number.isFinite(session.lastExternalChangeSignalAt) - && session.lastExternalChangeSignalAt > 0 - && (Date.now() - session.lastExternalChangeSignalAt) < 1500 - ); - - const sessionHasRecentLocalInput = (session) => ( - Number.isFinite(session.lastUserInputAt) - && session.lastUserInputAt > 0 - && (Date.now() - session.lastUserInputAt) < 1500 - ); - - const fetchLatestSessionAggregate = async (session) => { - const response = await fetch(pageAggregateUrl({ - documentId: session.documentId, - sourceKind: session.sourceKind, - workspaceId: session.workspaceId, - rootUri: session.rootUri, - }).toString(), { - cache: 'no-store', - headers: { accept: 'application/json' }, - }); - if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status); - const payload = await response.json(); - const nextAggregate = payload?.result; - if (!nextAggregate || typeof nextAggregate !== 'object') { - throw new Error('conflict_latest_missing_aggregate'); - } - return nextAggregate; - }; - - const fetchLatestResourceSnapshot = async (session) => { - const url = new URL('/api/local-folder/resource/read', window.location.origin); - url.searchParams.set('rootUri', session.rootUri || ''); - url.searchParams.set('path', session.resourcePath || ''); - const response = await fetch(url.toString(), { - cache: 'no-store', - headers: { accept: 'application/json' }, - }); - const payload = await response.json().catch(() => null); - if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status); - const nextResource = payload.result; - if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot'); - return nextResource; - }; - - const aggregatePlainText = (aggregate) => { - const body = aggregate?.body || {}; - return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim(); - }; - - const resourceSnapshotPlainText = (snapshot) => ( - String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '') - .replace(/\n{3,}/g, '\n\n') - .trim() - ); - - const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => { - if (!session || session.sourceKind !== 'local_folder') return false; - if (!sessionHasRecentExternalSignal(session)) return false; - if (!sessionPlainText(session)) return false; - return !aggregatePlainText(nextAggregate); - }; - - const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); - - const conflictEnvelopeFromResponse = (payload) => ( - payload?.error?.details?.conflict - || payload?.details?.conflict - || null - ); - - const conflictSourceLabel = (session) => { - if (!session) return ''; - if (session.lastExternalWriteSource === 'mnote-hermes-tool') { - const runId = String(session.lastExternalWriteRunId || '').trim(); - return runId ? `agent run ${runId}` : 'agent run'; - } - return '本地文件变更'; - }; - - const conflictMessageFromEnvelope = (session, envelope) => { - const baseMessage = envelope?.message || externalConflictMessage; - const sourceLabel = conflictSourceLabel(session); - return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage; - }; - - const ensureSessionConflictEnvelope = (session, message) => { - if (!session) return null; - if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope; - const envelope = { - code: 'local_markdown_external_change', - documentId: session.documentId, - rootUri: session.rootUri || null, - currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null, - editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null, - externalActor: session.lastExternalWriteSource || null, - dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), - bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null, - message: message || externalConflictMessage, - suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'], - }; - session.lastExternalConflictEnvelope = envelope; - return envelope; - }; - - const clearSessionConflictSurface = (session) => { - var _rt_ = window.__mnoteDocumentConflictPanelRuntime; - if (_rt_ && typeof _rt_.clearSessionConflictSurface === 'function') { - _rt_.clearSessionConflictSurface(session, { sessionViews: sessionViews }); - return; - } - sessionViews(session).forEach((view) => { - const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; - if (!(host instanceof HTMLElement)) return; - host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove()); - }); - }; - - const applyAggregateSnapshotToSession = (session, nextAggregate, source) => { - const nextBody = nextAggregate?.body || {}; - const nextPermissions = nextAggregate?.head?.permissions || {}; - const nextConflictKey = conflictDetectionKeyFromBody(nextBody); - const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); - const nextSerialized = JSON.stringify(nextTiptapDocument); - const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); - session.latestAggregate = nextAggregate; - syncPageAggregateScript(session, nextAggregate); - session.title = nextAggregate?.head?.title || session.title; - session.currentTiptapDocument = nextTiptapDocument; - session.currentSerialized = nextSerialized; - session.lastPersistedSerialized = nextSerialized; - session.revision = nextRevision; - session.conflictDetectionKey = nextConflictKey; - session.lastExternalConflictDetectionKey = nextConflictKey || ''; - session.readOnly = Boolean(nextPermissions.readOnly); - session.dirty = false; - session.hasExternalConflict = false; - session.externalChangePending = false; - session.lastUserInputAt = 0; - clearSessionConflictSurface(session); - sessionViews(session).forEach((view) => { - if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved'); - }); - setSessionStatus(session, 'synced-external-change'); - }; - - const applyResourceSnapshotToSession = (session, nextResource, source) => { - const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim(); - const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || ''); - const nextSerialized = JSON.stringify(nextTiptapDocument); - session.currentTiptapDocument = nextTiptapDocument; - session.currentSerialized = nextSerialized; - session.lastPersistedSerialized = nextSerialized; - if (nextConflictKey) { - session.conflictDetectionKey = nextConflictKey; - session.lastExternalConflictDetectionKey = nextConflictKey; - session.fileVersion = nextConflictKey; - } - session.dirty = false; - session.hasExternalConflict = false; - session.externalChangePending = false; - session.lastUserInputAt = 0; - clearSessionConflictSurface(session); - sessionViews(session).forEach((view) => { - if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved'); - }); - setSessionStatus(session, 'synced-external-change'); - }; - - const openConflictDiffPanel = async (session, panel) => { - const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]'); - if (!(diffPanel instanceof HTMLElement)) return; - diffPanel.hidden = false; - diffPanel.replaceChildren(); - const loading = document.createElement('div'); - loading.className = 'mnote-conflict-diff-status'; - loading.textContent = '正在读取磁盘版本...'; - diffPanel.appendChild(loading); - try { - const latest = session.sessionKind === 'resource' - ? await fetchLatestResourceSnapshot(session) - : await fetchLatestSessionAggregate(session); - const latestText = session.sessionKind === 'resource' - ? resourceSnapshotPlainText(latest) - : aggregatePlainText(latest); - diffPanel.replaceChildren(); - var _dpRT_ = window.__mnoteDocumentConflictPanelRuntime; - if (_dpRT_ && typeof _dpRT_.populateSessionConflictDiffPanel === 'function') { - _dpRT_.populateSessionConflictDiffPanel(diffPanel, { - currentText: sessionPlainText(session) || '(当前编辑器为空)', - diskText: latestText || '(磁盘版本为空)', - currentMergeText: sessionPlainText(session) || '', - diskMergeText: latestText || '', - mergeDefaultText: sessionPlainText(session) || latestText || '', - }, { - onUseCurrent: function() { - return sessionPlainText(session) || ''; - }, - onUseDisk: function() { - return latestText || ''; - }, - onSaveMerge: function(mergeText) { - writeMergedConflictResult(session, panel, mergeText).catch((error) => { - setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); - renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); - }); - }, - }); - } else { - const current = document.createElement('pre'); - current.setAttribute('data-testid', 'mnote-conflict-current-text'); - current.textContent = sessionPlainText(session) || '(当前编辑器为空)'; - const disk = document.createElement('pre'); - disk.setAttribute('data-testid', 'mnote-conflict-disk-text'); - disk.textContent = latestText || '(磁盘版本为空)'; - const currentTitle = document.createElement('h3'); - currentTitle.textContent = '当前编辑器版本'; - const diskTitle = document.createElement('h3'); - diskTitle.textContent = '磁盘版本'; - const currentBox = document.createElement('section'); - currentBox.append(currentTitle, current); - const diskBox = document.createElement('section'); - diskBox.append(diskTitle, disk); - const mergeTitle = document.createElement('h3'); - mergeTitle.textContent = '合并结果'; - const mergeText = document.createElement('textarea'); - mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text'); - mergeText.value = sessionPlainText(session) || latestText || ''; - const mergeActions = document.createElement('div'); - mergeActions.className = 'mnote-conflict-actions'; - const useCurrent = document.createElement('button'); - useCurrent.type = 'button'; - useCurrent.textContent = '使用当前版本'; - useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current'); - const useDisk = document.createElement('button'); - useDisk.type = 'button'; - useDisk.textContent = '使用磁盘版本'; - useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk'); - const saveMerge = document.createElement('button'); - saveMerge.type = 'button'; - saveMerge.textContent = '写回合并结果'; - saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save'); - mergeActions.append(useCurrent, useDisk, saveMerge); - const mergeBox = document.createElement('section'); - mergeBox.className = 'mnote-conflict-merge-box'; - mergeBox.append(mergeTitle, mergeText, mergeActions); - diffPanel.append(currentBox, diskBox, mergeBox); - useCurrent.addEventListener('click', () => { - mergeText.value = sessionPlainText(session) || ''; - }); - useDisk.addEventListener('click', () => { - mergeText.value = latestText || ''; - }); - saveMerge.addEventListener('click', () => { - writeMergedConflictResult(session, panel, mergeText.value).catch((error) => { - setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); - renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); - }); - }); - } - } catch (error) { - loading.textContent = error instanceof Error ? error.message : String(error); - diffPanel.replaceChildren(loading); - } - }; - - const acceptDiskVersion = async (session) => { - var _RT_ = window.__mnoteDocumentConflictPanelRuntime; - if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') { - return _RT_.acceptDiskVersion(session, { - setSessionStatus: setSessionStatus, - fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, - applyResourceSnapshotToSession: applyResourceSnapshotToSession, - fetchLatestSessionAggregate: fetchLatestSessionAggregate, - applyAggregateSnapshotToSession: applyAggregateSnapshotToSession, - }); - } - // inline fallback - setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...'); - if (session.sessionKind === 'resource') { - const latestResource = await fetchLatestResourceSnapshot(session); - applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk'); - return; - } - const latest = await fetchLatestSessionAggregate(session); - applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk'); - }; - - const keepCurrentEditorVersion = async (session) => { - var _RT_ = window.__mnoteDocumentConflictPanelRuntime; - if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') { - return _RT_.keepCurrentEditorVersion(session, { - setSessionStatus: setSessionStatus, - fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, - fetchLatestSessionAggregate: fetchLatestSessionAggregate, - sessionViews: sessionViews, - currentEditorText: currentEditorText, - normalizePlainText: normalizePlainText, - hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom, - textToTiptapDocument: textToTiptapDocument, - conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, - clearSessionConflictSurface: clearSessionConflictSurface, - persistSession: persistSession, - }); - } - // inline fallback - setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...'); - const latest = session.sessionKind === 'resource' - ? await fetchLatestResourceSnapshot(session) - : await fetchLatestSessionAggregate(session); - const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; - if (hydrateView) { - const liveText = normalizePlainText(currentEditorText(hydrateView)); - if (liveText) { - session.currentTiptapDocument = hydrateMindmapAttrsFromDom( - textToTiptapDocument(liveText), - hydrateView.runtimeDescriptor.root, - ); - } - session.currentSerialized = JSON.stringify(session.currentTiptapDocument); - } - const nextKey = session.sessionKind === 'resource' - ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() - : conflictDetectionKeyFromBody(latest.body || {}); - if (nextKey) { - session.conflictDetectionKey = nextKey; - session.lastExternalConflictDetectionKey = nextKey; - } - session.hasExternalConflict = false; - session.externalChangePending = false; - session.saving = false; - session.dirty = true; - clearSessionConflictSurface(session); - await persistSession(session); - }; - - const writeMergedConflictResult = async (session, panel, mergedText) => { - var _RT_ = window.__mnoteDocumentConflictPanelRuntime; - if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') { - return _RT_.writeMergedConflictResult(session, panel, mergedText, { - setSessionStatus: setSessionStatus, - fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, - fetchLatestSessionAggregate: fetchLatestSessionAggregate, - conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, - textToTiptapDocument: textToTiptapDocument, - persistSession: persistSession, - }); - } - // inline fallback - setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...'); - const latest = session.sessionKind === 'resource' - ? await fetchLatestResourceSnapshot(session) - : await fetchLatestSessionAggregate(session); - const nextKey = session.sessionKind === 'resource' - ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() - : conflictDetectionKeyFromBody(latest.body || {}); - if (nextKey) { - session.conflictDetectionKey = nextKey; - session.lastExternalConflictDetectionKey = nextKey; - } - session.currentTiptapDocument = textToTiptapDocument(String(mergedText || '')); - session.currentSerialized = JSON.stringify(session.currentTiptapDocument); - session.hasExternalConflict = false; - session.externalChangePending = false; - session.saving = false; - session.dirty = true; - if (panel && typeof panel.remove === 'function') panel.remove(); - await persistSession(session); - }; - - const runSessionConflictAction = (action, onError) => { - const runtime = window.__mnoteDocumentConflictPanelRuntime; - if (runtime && typeof runtime.runSessionConflictAction === 'function') { - runtime.runSessionConflictAction(action, { onError }); - return; - } - try { - const result = action(); - if (result && typeof result.then === 'function') result.catch(onError); - } catch (error) { - onError(error); - } - }; - - const renderSessionConflictSurface = (session, message) => { - clearSessionConflictSurface(session); - sessionViews(session).forEach((view) => { - const runtime = window.__mnoteDocumentConflictPanelRuntime; - const handleConflictActionError = (error) => { - setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); - renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); - }; - let panel = null; - if (runtime && typeof runtime.createSessionConflictPanel === 'function') { - panel = runtime.createSessionConflictPanel(session, message, { - externalConflictMessage, - conflictSourceLabel, - onAcceptDisk: () => { - runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); - }, - onKeepCurrent: () => { - runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); - }, - onOpenDiff: (_session, createdPanel) => { - openConflictDiffPanel(session, createdPanel); - }, - }); - } - if (!(panel instanceof HTMLElement)) { - panel = document.createElement('section'); - panel.className = 'mnote-editor-conflict-panel'; - panel.setAttribute('data-testid', 'mnote-editor-conflict-panel'); - panel.setAttribute('role', 'status'); - panel.setAttribute('aria-live', 'polite'); - - const heading = document.createElement('h2'); - heading.textContent = '文件冲突'; - const text = document.createElement('p'); - text.textContent = message || externalConflictMessage; - const meta = document.createElement('div'); - meta.className = 'mnote-conflict-meta'; - const fileLabel = session.sessionKind === 'resource' - ? `${session.rootUri || ''}/${session.resourcePath || session.documentId}` - : (session.rootUri || session.documentId); - meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`; - const actions = document.createElement('div'); - actions.className = 'mnote-conflict-actions'; - const acceptDisk = document.createElement('button'); - acceptDisk.type = 'button'; - acceptDisk.textContent = '接受磁盘版本'; - acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk'); - const keepCurrent = document.createElement('button'); - keepCurrent.type = 'button'; - keepCurrent.textContent = '保留当前编辑器版本'; - keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current'); - const openDiff = document.createElement('button'); - openDiff.type = 'button'; - openDiff.textContent = '打开 diff'; - openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff'); - actions.append(acceptDisk, keepCurrent, openDiff); - const diffPanel = document.createElement('div'); - diffPanel.className = 'mnote-conflict-diff-panel'; - diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel'); - diffPanel.hidden = true; - panel.append(heading, text, meta, actions, diffPanel); - - acceptDisk.addEventListener('click', () => { - runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); - }); - keepCurrent.addEventListener('click', () => { - runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); - }); - openDiff.addEventListener('click', () => { - openConflictDiffPanel(session, panel); - }); - } - - if (runtime && typeof runtime.mountSessionConflictPanel === 'function') { - if (runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)) { - return; - } - } - const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; - if (!(host instanceof HTMLElement)) return; - const header = host.querySelector('.document-shell-header'); - if (header && header.parentNode) { - header.parentNode.insertBefore(panel, header.nextSibling); - } else { - host.prepend(panel); - } - }); - }; - - const markSessionExternalConflict = (session, message) => { - session.externalChangePending = false; - ensureSessionConflictEnvelope(session, message); - session.hasExternalConflict = true; - if (session.saveTimer) { - window.clearTimeout(session.saveTimer); - session.saveTimer = 0; - } - const nextMessage = message || externalConflictMessage; - setSessionStatus(session, 'external-change-conflict', nextMessage); - renderSessionConflictSurface(session, nextMessage); - }; - - const queueSessionSave = (session) => { - if (session.readOnly || session.hasExternalConflict) return; - if (session.saveTimer) window.clearTimeout(session.saveTimer); - setSessionStatus(session, 'dirty'); - session.saveTimer = window.setTimeout(() => { - session.saveTimer = 0; - void persistSession(session); - }, 650); - }; - - const persistSession = async (session) => { - if (session.readOnly || session.saving || session.hasExternalConflict) return; - const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; - if (hydrateView) { - hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root); - session.currentSerialized = JSON.stringify(session.currentTiptapDocument); - } - const serialized = session.currentSerialized; - if (!session.dirty && serialized === session.lastPersistedSerialized) { - setSessionStatus(session, 'saved'); - return; - } - session.saving = true; - setSessionStatus(session, 'saving'); - try { - const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument); - const content = legacyBlocksFromEditorDocument(editorDocument); - const saveEndpoint = session.saveEndpoint || '/api/documents/save'; - const savePayload = session.sessionKind === 'resource' - ? { - rootUri: session.rootUri, - path: session.resourcePath, - expectedFileVersion: session.conflictDetectionKey, - contentFormat: 'editorBlocks', - editorSource: 'tiptap-resource-tab', - editorDocument, - content, - tiptapDocument: session.currentTiptapDocument, - blockCount: editorDocument.blocks.length, - } - : { - documentId: session.documentId, - workspaceId: session.workspaceId, - sourceKind: session.sourceKind, - rootUri: session.rootUri, - revision: session.revision, - expectedFileVersion: session.conflictDetectionKey, - contentFormat: 'editorBlocks', - editorSource: 'tiptap', - editorDocument, - content, - tiptapDocument: session.currentTiptapDocument, - blockCount: editorDocument.blocks.length, - }; - if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') { - savePayload.conflictDetectionKey = session.conflictDetectionKey; - } - markLocalFolderSelfChangeSuppression(session); - const response = await fetch(saveEndpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(savePayload), - }); - const result = await response.json().catch(() => null); - if (!response.ok || !result || result.ok !== true) { - const message = result?.error?.message || result?.message || `save_failed_${response.status}`; - const conflictEnvelope = conflictEnvelopeFromResponse(result); - if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') { - session.saving = false; - session.lastExternalConflictEnvelope = conflictEnvelope; - markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); - return; - } - throw new Error(message); - } - const saved = result.result || {}; - if (Number.isInteger(saved.revision)) session.revision = saved.revision; - if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) { - session.conflictDetectionKey = saved.conflict_detection_key.trim(); - } - if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) { - session.conflictDetectionKey = saved.conflictDetectionKey.trim(); - } - if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) { - session.conflictDetectionKey = saved.fileVersion.trim(); - } - if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey; - session.hasExternalConflict = false; - session.externalChangePending = false; - session.lastUserInputAt = 0; - syncSessionMetaToViews(session); - session.lastPersistedSerialized = serialized; - session.saving = false; - if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') { - const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0]; - if (primaryView) { - const plainText = sessionPlainText(session); - window.__mnoteRecordPageHistorySnapshot('save', { - wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length, - characterCount: plainText.replace(/\s/g, '').length, - blockCount: editorDocument.blocks.length, - todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length, - todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length, - }); - } - } - if (session.currentSerialized === serialized) { - session.dirty = false; - setSessionStatus(session, 'saved'); - } else { - session.dirty = true; - setSessionStatus(session, 'dirty'); - queueSessionSave(session); - } - syncResourceSessionTabGuards(session); - } catch (error) { - session.saving = false; - setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error)); - syncResourceSessionTabGuards(session); - } - }; - - const scheduleSessionExternalRefresh = (session, source) => { - if (!session || session.views.size === 0) return; - if (session.externalRefreshTimer) return; - session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change'; - session.externalRefreshTimer = window.setTimeout(() => { - const refreshSource = session.externalRefreshSource || 'mnote-web-external-change'; - session.externalRefreshSource = ''; - session.externalRefreshTimer = 0; - void refreshSessionFromExternalChange(session, refreshSource); - }, 120); - }; - - const refreshSessionFromExternalChange = async (session, source) => { - if (document.hidden) return; - if (!session || session.views.size === 0) return; - if (session.sourceKind === 'local_folder' && !session.rootUri) return; - try { - const response = await fetch(pageAggregateUrl({ - documentId: session.documentId, - sourceKind: session.sourceKind, - workspaceId: session.workspaceId, - rootUri: session.rootUri, - }).toString(), { - cache: 'no-store', - headers: { accept: 'application/json' }, - }); - if (!response.ok) { - if (session.sourceKind === 'local_folder') { - markSessionExternalConflict(session, externalConflictMessage); - } - return; - } - const payload = await response.json(); - const conflictEnvelope = conflictEnvelopeFromResponse(payload); - if (response.status === 409 && conflictEnvelope) { - session.lastExternalConflictEnvelope = conflictEnvelope; - markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); - return; - } - let nextAggregate = payload?.result; - if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) { - await delay(500); - try { - const retryAggregate = await fetchLatestSessionAggregate(session); - if (aggregatePlainText(retryAggregate)) { - nextAggregate = retryAggregate; - } else { - markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。'); - return; - } - } catch (_retryError) { - markSessionExternalConflict(session, externalConflictMessage); - return; - } - } - const nextBody = nextAggregate?.body || {}; - const nextPermissions = nextAggregate?.head?.permissions || {}; - const nextConflictKey = conflictDetectionKeyFromBody(nextBody); - const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); - const nextSerialized = JSON.stringify(nextTiptapDocument); - const contentChanged = nextSerialized !== session.currentSerialized; - session.externalChangePending = false; - if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { - session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; - if (!contentChanged) return; - } - if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return; - if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { - markSessionExternalConflict(session, externalConflictMessage); - return; - } - const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); - session.latestAggregate = nextAggregate; - syncPageAggregateScript(session, nextAggregate); - session.title = nextAggregate?.head?.title || session.title; - session.currentTiptapDocument = nextTiptapDocument; - session.currentSerialized = nextSerialized; - session.lastPersistedSerialized = session.currentSerialized; - session.revision = nextRevision; - session.conflictDetectionKey = nextConflictKey; - session.lastExternalConflictDetectionKey = nextConflictKey || ''; - session.readOnly = Boolean(nextPermissions.readOnly); - session.dirty = false; - session.hasExternalConflict = false; - session.lastUserInputAt = 0; - sessionViews(session).forEach((view) => { - if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change'); - }); - setSessionStatus(session, 'synced-external-change'); - } catch (error) { - console.warn('mnote 页面外部更新检测失败', error); - } - }; - - const refreshSessionFromExternalFileChange = async (session) => { - if (session.sourceKind !== 'local_folder') return; - await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); - }; - - const ensureLocalFolderEventChannel = (session) => { - if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') { - return; - } - const channelKey = localFolderEventChannelKey(session); - let channel = localFolderEventRegistry.get(channelKey); - if (!channel) { - const url = new URL('/api/local-folder/events', window.location.origin); - url.searchParams.set('rootUri', session.rootUri); - url.searchParams.set('documentId', session.documentId); - const eventSource = new EventSource(url.toString()); - channel = { - key: channelKey, - rootUri: session.rootUri, - documentId: session.documentId, - eventSource, - sessions: new Map(), - }; - eventSource.addEventListener('change', (event) => { - const payload = parseLocalFolderEventPayload(event); - if (!payload) return; - Array.from(channel.sessions.values()).forEach((targetSession) => { - if (!targetSession || targetSession.views.size === 0) return; - const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; - if (!documentId) return; - const eventKind = String(payload.eventKind || ''); - const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); - if (documentId && !targetsCurrentDocument) return; - if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) { - targetSession.externalChangePending = false; - targetSession.lastSelfSaveSignalAt = Date.now(); - return; - } - if (targetsCurrentDocument && targetSession.saving) { - targetSession.externalChangePending = false; - targetSession.lastSelfSaveSignalAt = Date.now(); - return; - } - targetSession.lastExternalChangeSignalAt = Date.now(); - targetSession.externalChangePending = true; - if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) { - markSessionExternalConflict(targetSession, externalConflictMessage); - return; - } - scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); - }); - }); - eventSource.onerror = () => { - console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连'); - }; - localFolderEventRegistry.set(channelKey, channel); - } - channel.sessions.set(session.key, session); - session.localFolderChannel = channel; - }; - - const readTreePayloadData = (payload) => ( - payload && typeof payload === 'object' - ? (payload.data || payload.delta || payload) - : null - ); - - const readTreePayloadOverview = (payload) => ( - payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object' - ? payload.overview - : null - ); - - const readTreePayloadCursor = (payload) => { - const raw = String(payload?.cursor || payload?.revision || '').trim(); - if (!raw) return { id: '', createdAt: '', raw: '' }; - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return { - id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(), - createdAt: String(parsed.createdAt || parsed.created_at || '').trim(), - raw, - }; - } - } catch (_) {} - return { id: raw, createdAt: '', raw }; - }; - - const treeRecordMatchesPayloadCursor = (record, payload) => { - if (!record || typeof record !== 'object') return false; - const cursor = readTreePayloadCursor(payload); - if (!cursor.id && !cursor.createdAt && !cursor.raw) return false; - const ids = [ - record.id, - record._id, - record.command_log_id, - record.commandLogId, - record.domain_event_id, - record.domainEventId, - record.command_id, - record.commandId, - ].map((value) => String(value || '').trim()).filter(Boolean); - if (cursor.id && ids.includes(cursor.id)) return true; - const createdAt = String(record.created_at || record.createdAt || '').trim(); - return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt); - }; - - const treeRecordTargetsDocument = (record, documentId) => { - if (!record || typeof record !== 'object' || !documentId) return false; - const targetPageId = String(record.target_page_id || record.targetPageId || '').trim(); - const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); - if (targetPageId === documentId || aggregateId === documentId) return true; - const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; - if (!payload) return false; - const streamDelta = payload.streamDelta || payload.stream_delta || null; - const deltaDocumentId = streamDelta && typeof streamDelta === 'object' - ? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim() - : ''; - return deltaDocumentId === documentId; - }; - - const collectMindmapIdsFromTreeRecord = (record, documentId, out) => { - if (!record || typeof record !== 'object' || !documentId) return; - if (!treeRecordTargetsDocument(record, documentId)) return; - const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim(); - if (targetBlockId) out.add(targetBlockId); - const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim(); - const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); - if (aggregateType === 'block' && aggregateId) out.add(aggregateId); - const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; - const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null; - const blockId = streamDelta && typeof streamDelta === 'object' - ? String(streamDelta.blockId || streamDelta.block_id || '').trim() - : ''; - if (blockId) out.add(blockId); - }; - - const collectMindmapIdsFromTreePayload = (payload, session) => { - const ids = new Set(); - if (!payload || typeof payload !== 'object' || !session?.documentId) return []; - const kind = String(payload.kind || '').trim(); - const data = readTreePayloadData(payload); - if (kind === 'delta' && data && typeof data === 'object') { - const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); - if (!documentId || documentId === session.documentId) { - const blockId = String(data.blockId || data.block_id || '').trim(); - if (blockId) ids.add(blockId); - const streamDelta = data.streamDelta || data.stream_delta || null; - const streamBlockId = streamDelta && typeof streamDelta === 'object' - ? String(streamDelta.blockId || streamDelta.block_id || '').trim() - : ''; - if (streamBlockId) ids.add(streamBlockId); - } - } - if (kind === 'resync') { - const overview = readTreePayloadOverview(payload); - if (overview) { - const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; - const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; - commandLogs - .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) - .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); - domainEvents - .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) - .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); - } - } - return Array.from(ids); - }; - - const refreshMindmapRuntimesFromTreePayload = (payload, session) => { - const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {}; - collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => { - const bridge = registry[mindmapId]; - if (bridge && typeof bridge.refreshProjection === 'function') { - void bridge.refreshProjection('mnote-web-tree-live'); - } - }); - }; - - const treePayloadTargetsDocument = (payload, session) => { - if (!payload || typeof payload !== 'object' || !session?.documentId) return false; - if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false; - const data = readTreePayloadData(payload); - if (data && typeof data === 'object') { - const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); - if (documentId === session.documentId) return true; - const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : []; - if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true; - } - const overview = readTreePayloadOverview(payload); - if (!overview) return false; - const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; - const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; - return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId)) - || domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId)); - }; - - const handleTreeExternalChange = (event) => { - const payload = event?.detail?.payload || event?.detail || null; - if (!payload) return; - Array.from(documentSessionRegistry.values()).forEach((session) => { - if (session.sourceKind === 'local_folder') return; - if (!treePayloadTargetsDocument(payload, session)) return; - refreshMindmapRuntimesFromTreePayload(payload, session); - session.lastExternalChangeSignalAt = Date.now(); - session.externalChangePending = true; - if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { - markSessionExternalConflict(session, treeExternalConflictMessage); - return; - } - scheduleSessionExternalRefresh(session, 'mnote-web-tree-live'); - }); - }; - - const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => { - if (!session) return false; - const doc = String(documentId || '').trim(); - const workspace = String(workspaceId || '').trim(); - if (doc && session.documentId !== doc) return false; - if (workspace && session.workspaceId && session.workspaceId !== workspace) return false; - return true; - }; - - const refreshDocumentSessionsFromExternalWrite = (detail, source) => { - const documentId = String(detail?.documentId || '').trim(); - const workspaceId = String(detail?.workspaceId || '').trim(); - let scheduled = 0; - Array.from(documentSessionRegistry.values()).forEach((session) => { - if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return; - session.lastExternalChangeSignalAt = Date.now(); - session.externalChangePending = true; - session.lastExternalWriteSource = source || session.lastExternalWriteSource || ''; - session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim(); - session.lastExternalWriteDocumentId = documentId; - if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { - markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage); - return; - } - scheduled += 1; - scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write'); - }); - return scheduled; - }; - - window.addEventListener('tree:delta', handleTreeExternalChange); - window.addEventListener('tree:resync', handleTreeExternalChange); - window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { - refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); + const documentSessions = createDocumentSessionRuntime({ + bridgeProtocol: BRIDGE_PROTOCOL, + commandEvent: COMMAND_EVENT, + normalizeEnvelopePayload, + pageAggregateUrl, + setStatus, + syncPageAggregateScript, + syncResourceSessionTabGuards, }); - - const createDocumentSession = (runtimeDescriptor) => { - const pageBody = runtimeDescriptor.aggregate.body || {}; - const permissions = runtimeDescriptor.aggregate.head?.permissions || {}; - const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody); - const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null; - const keyRevision = revisionFromConflictKey(conflictDetectionKey); - const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); - const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap); - const session = { - key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), - documentId: runtimeDescriptor.bootstrap.documentId, - workspaceId: runtimeDescriptor.bootstrap.workspaceId, - sourceKind, - rootUri: runtimeDescriptor.bootstrap.rootUri, - saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save', - pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__', - latestAggregate: runtimeDescriptor.aggregate, - title: runtimeDescriptor.aggregate.head?.title || '无标题', - currentTiptapDocument: tiptapDocument, - currentSerialized: JSON.stringify(tiptapDocument), - lastPersistedSerialized: JSON.stringify(tiptapDocument), - revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision, - conflictDetectionKey, - fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey, - lastExternalConflictDetectionKey: conflictDetectionKey || '', - readOnly: Boolean(permissions.readOnly), - dirty: false, - saving: false, - hasExternalConflict: false, - externalChangePending: false, - externalRefreshSource: '', - lastExternalChangeSignalAt: 0, - lastSelfSaveSignalAt: 0, - lastUserInputAt: 0, - status: 'booting', - error: null, - saveTimer: 0, - externalRefreshTimer: 0, - releaseTimer: 0, - views: new Map(), - localFolderChannel: null, - }; - ensureLocalFolderEventChannel(session); - return session; - }; - - const getOrCreateDocumentSession = (runtimeDescriptor) => { - const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap); - const existing = documentSessionRegistry.get(key); - if (existing) { - cancelDocumentSessionRelease(existing); - return existing; - } - const session = createDocumentSession(runtimeDescriptor); - documentSessionRegistry.set(key, session); - return session; - }; + const documentSessionRegistry = documentSessions.documentSessionRegistry; + const externalConflictMessage = documentSessions.externalConflictMessage; + const sessionViews = (...args) => documentSessions.sessionViews(...args); + const releaseDocumentSession = (...args) => documentSessions.releaseDocumentSession(...args); + const scheduleDocumentSessionRelease = (...args) => documentSessions.scheduleDocumentSessionRelease(...args); + const cancelDocumentSessionRelease = (...args) => documentSessions.cancelDocumentSessionRelease(...args); + const currentEditorText = (...args) => documentSessions.currentEditorText(...args); + const clearEmbeddedLocalDraft = (...args) => documentSessions.clearEmbeddedLocalDraft(...args); + const setSessionStatus = (...args) => documentSessions.setSessionStatus(...args); + const dispatchSessionContentToView = (...args) => documentSessions.dispatchSessionContentToView(...args); + const broadcastSessionContent = (...args) => documentSessions.broadcastSessionContent(...args); + const sessionHasRecentExternalSignal = (...args) => documentSessions.sessionHasRecentExternalSignal(...args); + const sessionHasRecentLocalInput = (...args) => documentSessions.sessionHasRecentLocalInput(...args); + const clearSessionConflictSurface = (...args) => documentSessions.clearSessionConflictSurface(...args); + const markSessionExternalConflict = (...args) => documentSessions.markSessionExternalConflict(...args); + const queueSessionSave = (...args) => documentSessions.queueSessionSave(...args); + const refreshSessionFromExternalChange = (...args) => documentSessions.refreshSessionFromExternalChange(...args); + const sessionMatchesDocumentWorkspace = (...args) => documentSessions.sessionMatchesDocumentWorkspace(...args); + const getOrCreateDocumentSession = (...args) => documentSessions.getOrCreateDocumentSession(...args); const updatePaneChrome = (runtimeDescriptor) => { const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]'); diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js new file mode 100644 index 00000000..d9e6bfa2 --- /dev/null +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -0,0 +1,1362 @@ +import { + conflictDetectionKeyFromBody, + editorDocumentFromTiptapDocument, + flattenText, + legacyBlocksFromEditorDocument, + pageBodyTiptapDocument, + revisionFromConflictKey, + toTiptapDocument, +} from './document-tiptap-conversion-runtime.js'; + +export const createDocumentSessionRuntime = (dependencies = {}) => { + const { + bridgeProtocol, + commandEvent, + normalizeEnvelopePayload, + pageAggregateUrl, + setStatus, + syncPageAggregateScript, + syncResourceSessionTabGuards, + } = dependencies; + const documentSessionRegistry = new Map(); + const localFolderEventRegistry = new Map(); + const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; + const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; + const SESSION_RELEASE_DELAY_MS = 1200; + const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1'; + + const ensureLocalFolderSelfChangeSuppressions = () => { + const now = Date.now(); + const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map + ? window.__mnoteLocalFolderSelfChangeSuppressions + : new Map(); + window.__mnoteLocalFolderSelfChangeSuppressions = map; + try { + const raw = window.sessionStorage ? window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY) : ''; + const parsed = raw ? JSON.parse(raw) : null; + if (parsed && typeof parsed === 'object') { + Object.entries(parsed).forEach(([documentId, expiresAt]) => { + const doc = String(documentId || '').trim(); + const expiry = Number(expiresAt || 0); + if (doc && Number.isFinite(expiry) && expiry > now) { + map.set(doc, expiry); + } else if (doc) { + map.delete(doc); + delete parsed[documentId]; + } + }); + if (window.sessionStorage) window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(parsed)); + } + } catch (_) {} + return map; + }; + + const parseLocalFolderEventPayload = (event) => { + try { + return JSON.parse(String(event?.data || '{}')); + } catch (_) { + return null; + } + }; + + const shouldSuppressLocalFolderSelfChange = (documentId, eventKind) => { + const doc = String(documentId || '').trim(); + if (!doc) return false; + const kind = String(eventKind || ''); + const suppressibleSelfWrite = kind.includes('Create') + || kind.includes('Metadata') + || kind.includes('Modify(Data') + || kind.includes('Modify(Any') + || kind.includes('Modify(Name'); + if (!suppressibleSelfWrite) return false; + const suppressions = ensureLocalFolderSelfChangeSuppressions(); + if (!suppressions || typeof suppressions.get !== 'function') return false; + const expiresAt = Number(suppressions.get(doc) || 0); + if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false; + if (Date.now() > expiresAt) { + if (typeof suppressions.delete === 'function') suppressions.delete(doc); + return false; + } + return true; + }; + + const markLocalFolderSelfChangeSuppression = (session, ttlMs = 5000) => { + if (!session || session.sourceKind !== 'local_folder') return; + const doc = String(session.documentId || '').trim(); + if (!doc) return; + const suppressions = ensureLocalFolderSelfChangeSuppressions(); + if (!suppressions || typeof suppressions.set !== 'function') return; + const expiresAt = Date.now() + Math.max(1000, Number(ttlMs) || 5000); + suppressions.set(doc, expiresAt); + try { + if (!window.sessionStorage) return; + const raw = window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + const next = parsed && typeof parsed === 'object' ? parsed : {}; + next[doc] = expiresAt; + window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(next)); + } catch (_) {} + }; + + const normalizeSessionSourceKind = (bootstrap) => { + const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : ''; + return value || 'convex_workspace'; + }; + + const buildDocumentSessionKey = (bootstrap) => { + const sourceKind = normalizeSessionSourceKind(bootstrap); + const scope = sourceKind === 'local_folder' + ? String(bootstrap?.rootUri || '').trim() + : String(bootstrap?.workspaceId || '').trim(); + return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`; + }; + + const sessionViews = (session) => Array.from(session.views.values()); + + const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`; + + const detachSessionFromLocalFolderChannel = (session) => { + const channel = session.localFolderChannel; + if (!channel) return; + channel.sessions.delete(session.key); + if (channel.sessions.size === 0) { + try { + channel.eventSource.close(); + } catch (_) { + // noop + } + localFolderEventRegistry.delete(channel.key); + } + session.localFolderChannel = null; + }; + + const releaseDocumentSession = (session) => { + if (session.saveTimer) { + window.clearTimeout(session.saveTimer); + session.saveTimer = 0; + } + if (session.externalRefreshTimer) { + window.clearTimeout(session.externalRefreshTimer); + session.externalRefreshTimer = 0; + } + if (session.releaseTimer) { + window.clearTimeout(session.releaseTimer); + session.releaseTimer = 0; + } + detachSessionFromLocalFolderChannel(session); + if (documentSessionRegistry.get(session.key) === session) { + documentSessionRegistry.delete(session.key); + } + }; + + const scheduleDocumentSessionRelease = (session) => { + if (session.views.size > 0) return; + if (session.releaseTimer) window.clearTimeout(session.releaseTimer); + session.releaseTimer = window.setTimeout(() => { + session.releaseTimer = 0; + if (session.views.size === 0) { + releaseDocumentSession(session); + } + }, SESSION_RELEASE_DELAY_MS); + }; + + const cancelDocumentSessionRelease = (session) => { + if (!session.releaseTimer) return; + window.clearTimeout(session.releaseTimer); + session.releaseTimer = 0; + }; + + window.__mnoteDebugDocumentSessions = { + snapshot: () => ({ + sessionCount: documentSessionRegistry.size, + sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({ + key, + documentId: session.documentId, + sourceKind: session.sourceKind, + rootUri: session.rootUri, + workspaceId: session.workspaceId, + viewCount: session.views.size, + status: session.status, + conflictDetectionKey: session.conflictDetectionKey || '', + lastExternalConflictDetectionKey: session.lastExternalConflictDetectionKey || '', + lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null, + dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), + })), + localFolderChannelCount: localFolderEventRegistry.size, + localFolderRoots: Array.from(localFolderEventRegistry.keys()), + }), + }; + + const currentEditorText = (view) => { + const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); + return editor?.textContent || ''; + }; + + const findScrollableEditorContainer = (view) => { + let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); + while (current instanceof HTMLElement) { + if (current.scrollHeight > current.clientHeight + 8) { + return current; + } + current = current.parentElement; + } + return null; + }; + + const preserveViewScrollPosition = (view, operation) => { + const scrollable = findScrollableEditorContainer(view); + const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null; + const viewportTop = window.scrollY; + const viewportLeft = window.scrollX; + operation(); + const restore = () => { + const current = findScrollableEditorContainer(view); + if (current instanceof HTMLElement && scrollTop != null) { + current.scrollTop = scrollTop; + } + if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) { + window.scrollTo({ + top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY, + left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX, + behavior: 'auto', + }); + } + }; + window.requestAnimationFrame(() => { + restore(); + window.setTimeout(restore, 0); + window.setTimeout(restore, 80); + window.setTimeout(restore, 180); + window.setTimeout(restore, 320); + }); + }; + + const dispatchRuntimeCommand = (view, payload, source) => { + view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(commandEvent, { + bubbles: true, + detail: { + protocol: bridgeProtocol, + runtime: 'mnote-leptos-tiptap-spike', + version: '1.1.0', + source: source || 'mnote-web-document-session', + event: commandEvent, + payload, + }, + })); + }; + + const clearEmbeddedLocalDraft = (runtimeDescriptor) => { + if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return; + try { + const storage = window.localStorage; + const base = 'mnote.leptos-tiptap-spike.document'; + const keys = [ + `${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`, + `${base}:${runtimeDescriptor.bootstrap.documentId}`, + ]; + for (const key of keys) storage.removeItem(key); + } catch (error) { + console.warn('mnote local folder 草稿清理失败', error); + } + }; + + const setSessionStatus = (session, status, message) => { + session.status = status; + session.error = message || null; + sessionViews(session).forEach((view) => { + setStatus(view.runtimeDescriptor, status, message); + }); + }; + + const dispatchSessionContentToView = (session, view, source) => { + view.suppressedSerialized = session.currentSerialized; + view.lastKnownSerialized = session.currentSerialized; + preserveViewScrollPosition(view, () => { + dispatchRuntimeCommand(view, { + command: 'replaceContent', + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + content: session.currentTiptapDocument, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: session.readOnly, + editable: !session.readOnly, + }, source || 'mnote-web-document-session-sync'); + }); + }; + + const dispatchSessionMetaToView = (session, view, source) => { + dispatchRuntimeCommand(view, { + command: 'replaceContent', + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: session.readOnly, + editable: !session.readOnly, + }, source || 'mnote-web-document-session-meta'); + }; + + const syncSessionMetaToViews = (session) => { + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionMetaToView(session, view); + }); + }; + + const broadcastSessionContent = (session, sourceView) => { + sessionViews(session).forEach((view) => { + if (sourceView && view.id === sourceView.id) return; + if (view.mountId == null) return; + dispatchSessionContentToView(session, view); + }); + }; + + const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + + const sessionPlainText = (session) => { + const liveText = sessionViews(session) + .map((view) => normalizePlainText(currentEditorText(view))) + .find((text) => text); + if (liveText) return liveText; + return normalizePlainText(flattenText(session.currentTiptapDocument)); + }; + + const sessionHasRecentExternalSignal = (session) => ( + session.sourceKind === 'local_folder' + && Number.isFinite(session.lastExternalChangeSignalAt) + && session.lastExternalChangeSignalAt > 0 + && (Date.now() - session.lastExternalChangeSignalAt) < 1500 + ); + + const sessionHasRecentLocalInput = (session) => ( + Number.isFinite(session.lastUserInputAt) + && session.lastUserInputAt > 0 + && (Date.now() - session.lastUserInputAt) < 1500 + ); + + const fetchLatestSessionAggregate = async (session) => { + const response = await fetch(pageAggregateUrl({ + documentId: session.documentId, + sourceKind: session.sourceKind, + workspaceId: session.workspaceId, + rootUri: session.rootUri, + }).toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status); + const payload = await response.json(); + const nextAggregate = payload?.result; + if (!nextAggregate || typeof nextAggregate !== 'object') { + throw new Error('conflict_latest_missing_aggregate'); + } + return nextAggregate; + }; + + const fetchLatestResourceSnapshot = async (session) => { + const url = new URL('/api/local-folder/resource/read', window.location.origin); + url.searchParams.set('rootUri', session.rootUri || ''); + url.searchParams.set('path', session.resourcePath || ''); + const response = await fetch(url.toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status); + const nextResource = payload.result; + if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot'); + return nextResource; + }; + + const aggregatePlainText = (aggregate) => { + const body = aggregate?.body || {}; + return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim(); + }; + + const resourceSnapshotPlainText = (snapshot) => ( + String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '') + .replace(/\n{3,}/g, '\n\n') + .trim() + ); + + const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => { + if (!session || session.sourceKind !== 'local_folder') return false; + if (!sessionHasRecentExternalSignal(session)) return false; + if (!sessionPlainText(session)) return false; + return !aggregatePlainText(nextAggregate); + }; + + const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + + const conflictEnvelopeFromResponse = (payload) => ( + payload?.error?.details?.conflict + || payload?.details?.conflict + || null + ); + + const conflictSourceLabel = (session) => { + if (!session) return ''; + if (session.lastExternalWriteSource === 'mnote-hermes-tool') { + const runId = String(session.lastExternalWriteRunId || '').trim(); + return runId ? `agent run ${runId}` : 'agent run'; + } + return '本地文件变更'; + }; + + const conflictMessageFromEnvelope = (session, envelope) => { + const baseMessage = envelope?.message || externalConflictMessage; + const sourceLabel = conflictSourceLabel(session); + return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage; + }; + + const ensureSessionConflictEnvelope = (session, message) => { + if (!session) return null; + if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope; + const envelope = { + code: 'local_markdown_external_change', + documentId: session.documentId, + rootUri: session.rootUri || null, + currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null, + editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null, + externalActor: session.lastExternalWriteSource || null, + dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), + bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null, + message: message || externalConflictMessage, + suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'], + }; + session.lastExternalConflictEnvelope = envelope; + return envelope; + }; + + const clearSessionConflictSurface = (session) => { + var _rt_ = window.__mnoteDocumentConflictPanelRuntime; + if (_rt_ && typeof _rt_.clearSessionConflictSurface === 'function') { + _rt_.clearSessionConflictSurface(session, { sessionViews: sessionViews }); + return; + } + sessionViews(session).forEach((view) => { + const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; + if (!(host instanceof HTMLElement)) return; + host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove()); + }); + }; + + const applyAggregateSnapshotToSession = (session, nextAggregate, source) => { + const nextBody = nextAggregate?.body || {}; + const nextPermissions = nextAggregate?.head?.permissions || {}; + const nextConflictKey = conflictDetectionKeyFromBody(nextBody); + const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); + const nextSerialized = JSON.stringify(nextTiptapDocument); + const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); + session.latestAggregate = nextAggregate; + syncPageAggregateScript(session, nextAggregate); + session.title = nextAggregate?.head?.title || session.title; + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = nextSerialized; + session.revision = nextRevision; + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey || ''; + session.readOnly = Boolean(nextPermissions.readOnly); + session.dirty = false; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved'); + }); + setSessionStatus(session, 'synced-external-change'); + }; + + const applyResourceSnapshotToSession = (session, nextResource, source) => { + const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim(); + const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || ''); + const nextSerialized = JSON.stringify(nextTiptapDocument); + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = nextSerialized; + if (nextConflictKey) { + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey; + session.fileVersion = nextConflictKey; + } + session.dirty = false; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved'); + }); + setSessionStatus(session, 'synced-external-change'); + }; + + const openConflictDiffPanel = async (session, panel) => { + const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]'); + if (!(diffPanel instanceof HTMLElement)) return; + diffPanel.hidden = false; + diffPanel.replaceChildren(); + const loading = document.createElement('div'); + loading.className = 'mnote-conflict-diff-status'; + loading.textContent = '正在读取磁盘版本...'; + diffPanel.appendChild(loading); + try { + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const latestText = session.sessionKind === 'resource' + ? resourceSnapshotPlainText(latest) + : aggregatePlainText(latest); + diffPanel.replaceChildren(); + var _dpRT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_dpRT_ && typeof _dpRT_.populateSessionConflictDiffPanel === 'function') { + _dpRT_.populateSessionConflictDiffPanel(diffPanel, { + currentText: sessionPlainText(session) || '(当前编辑器为空)', + diskText: latestText || '(磁盘版本为空)', + currentMergeText: sessionPlainText(session) || '', + diskMergeText: latestText || '', + mergeDefaultText: sessionPlainText(session) || latestText || '', + }, { + onUseCurrent: function() { + return sessionPlainText(session) || ''; + }, + onUseDisk: function() { + return latestText || ''; + }, + onSaveMerge: function(mergeText) { + writeMergedConflictResult(session, panel, mergeText).catch((error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }); + }, + }); + } else { + const current = document.createElement('pre'); + current.setAttribute('data-testid', 'mnote-conflict-current-text'); + current.textContent = sessionPlainText(session) || '(当前编辑器为空)'; + const disk = document.createElement('pre'); + disk.setAttribute('data-testid', 'mnote-conflict-disk-text'); + disk.textContent = latestText || '(磁盘版本为空)'; + const currentTitle = document.createElement('h3'); + currentTitle.textContent = '当前编辑器版本'; + const diskTitle = document.createElement('h3'); + diskTitle.textContent = '磁盘版本'; + const currentBox = document.createElement('section'); + currentBox.append(currentTitle, current); + const diskBox = document.createElement('section'); + diskBox.append(diskTitle, disk); + const mergeTitle = document.createElement('h3'); + mergeTitle.textContent = '合并结果'; + const mergeText = document.createElement('textarea'); + mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text'); + mergeText.value = sessionPlainText(session) || latestText || ''; + const mergeActions = document.createElement('div'); + mergeActions.className = 'mnote-conflict-actions'; + const useCurrent = document.createElement('button'); + useCurrent.type = 'button'; + useCurrent.textContent = '使用当前版本'; + useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current'); + const useDisk = document.createElement('button'); + useDisk.type = 'button'; + useDisk.textContent = '使用磁盘版本'; + useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk'); + const saveMerge = document.createElement('button'); + saveMerge.type = 'button'; + saveMerge.textContent = '写回合并结果'; + saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save'); + mergeActions.append(useCurrent, useDisk, saveMerge); + const mergeBox = document.createElement('section'); + mergeBox.className = 'mnote-conflict-merge-box'; + mergeBox.append(mergeTitle, mergeText, mergeActions); + diffPanel.append(currentBox, diskBox, mergeBox); + useCurrent.addEventListener('click', () => { + mergeText.value = sessionPlainText(session) || ''; + }); + useDisk.addEventListener('click', () => { + mergeText.value = latestText || ''; + }); + saveMerge.addEventListener('click', () => { + writeMergedConflictResult(session, panel, mergeText.value).catch((error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }); + }); + } + } catch (error) { + loading.textContent = error instanceof Error ? error.message : String(error); + diffPanel.replaceChildren(loading); + } + }; + + const acceptDiskVersion = async (session) => { + var _RT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') { + return _RT_.acceptDiskVersion(session, { + setSessionStatus: setSessionStatus, + fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, + applyResourceSnapshotToSession: applyResourceSnapshotToSession, + fetchLatestSessionAggregate: fetchLatestSessionAggregate, + applyAggregateSnapshotToSession: applyAggregateSnapshotToSession, + }); + } + // inline fallback + setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...'); + if (session.sessionKind === 'resource') { + const latestResource = await fetchLatestResourceSnapshot(session); + applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk'); + return; + } + const latest = await fetchLatestSessionAggregate(session); + applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk'); + }; + + const keepCurrentEditorVersion = async (session) => { + var _RT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') { + return _RT_.keepCurrentEditorVersion(session, { + setSessionStatus: setSessionStatus, + fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, + fetchLatestSessionAggregate: fetchLatestSessionAggregate, + sessionViews: sessionViews, + currentEditorText: currentEditorText, + normalizePlainText: normalizePlainText, + hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom, + textToTiptapDocument: textToTiptapDocument, + conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, + clearSessionConflictSurface: clearSessionConflictSurface, + persistSession: persistSession, + }); + } + // inline fallback + setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...'); + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; + if (hydrateView) { + const liveText = normalizePlainText(currentEditorText(hydrateView)); + if (liveText) { + session.currentTiptapDocument = hydrateMindmapAttrsFromDom( + textToTiptapDocument(liveText), + hydrateView.runtimeDescriptor.root, + ); + } + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + } + const nextKey = session.sessionKind === 'resource' + ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() + : conflictDetectionKeyFromBody(latest.body || {}); + if (nextKey) { + session.conflictDetectionKey = nextKey; + session.lastExternalConflictDetectionKey = nextKey; + } + session.hasExternalConflict = false; + session.externalChangePending = false; + session.saving = false; + session.dirty = true; + clearSessionConflictSurface(session); + await persistSession(session); + }; + + const writeMergedConflictResult = async (session, panel, mergedText) => { + var _RT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') { + return _RT_.writeMergedConflictResult(session, panel, mergedText, { + setSessionStatus: setSessionStatus, + fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, + fetchLatestSessionAggregate: fetchLatestSessionAggregate, + conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, + textToTiptapDocument: textToTiptapDocument, + persistSession: persistSession, + }); + } + // inline fallback + setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...'); + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const nextKey = session.sessionKind === 'resource' + ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() + : conflictDetectionKeyFromBody(latest.body || {}); + if (nextKey) { + session.conflictDetectionKey = nextKey; + session.lastExternalConflictDetectionKey = nextKey; + } + session.currentTiptapDocument = textToTiptapDocument(String(mergedText || '')); + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + session.hasExternalConflict = false; + session.externalChangePending = false; + session.saving = false; + session.dirty = true; + if (panel && typeof panel.remove === 'function') panel.remove(); + await persistSession(session); + }; + + const runSessionConflictAction = (action, onError) => { + const runtime = window.__mnoteDocumentConflictPanelRuntime; + if (runtime && typeof runtime.runSessionConflictAction === 'function') { + runtime.runSessionConflictAction(action, { onError }); + return; + } + try { + const result = action(); + if (result && typeof result.then === 'function') result.catch(onError); + } catch (error) { + onError(error); + } + }; + + const renderSessionConflictSurface = (session, message) => { + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + const runtime = window.__mnoteDocumentConflictPanelRuntime; + const handleConflictActionError = (error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }; + let panel = null; + if (runtime && typeof runtime.createSessionConflictPanel === 'function') { + panel = runtime.createSessionConflictPanel(session, message, { + externalConflictMessage, + conflictSourceLabel, + onAcceptDisk: () => { + runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); + }, + onKeepCurrent: () => { + runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); + }, + onOpenDiff: (_session, createdPanel) => { + openConflictDiffPanel(session, createdPanel); + }, + }); + } + if (!(panel instanceof HTMLElement)) { + panel = document.createElement('section'); + panel.className = 'mnote-editor-conflict-panel'; + panel.setAttribute('data-testid', 'mnote-editor-conflict-panel'); + panel.setAttribute('role', 'status'); + panel.setAttribute('aria-live', 'polite'); + + const heading = document.createElement('h2'); + heading.textContent = '文件冲突'; + const text = document.createElement('p'); + text.textContent = message || externalConflictMessage; + const meta = document.createElement('div'); + meta.className = 'mnote-conflict-meta'; + const fileLabel = session.sessionKind === 'resource' + ? `${session.rootUri || ''}/${session.resourcePath || session.documentId}` + : (session.rootUri || session.documentId); + meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`; + const actions = document.createElement('div'); + actions.className = 'mnote-conflict-actions'; + const acceptDisk = document.createElement('button'); + acceptDisk.type = 'button'; + acceptDisk.textContent = '接受磁盘版本'; + acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk'); + const keepCurrent = document.createElement('button'); + keepCurrent.type = 'button'; + keepCurrent.textContent = '保留当前编辑器版本'; + keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current'); + const openDiff = document.createElement('button'); + openDiff.type = 'button'; + openDiff.textContent = '打开 diff'; + openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff'); + actions.append(acceptDisk, keepCurrent, openDiff); + const diffPanel = document.createElement('div'); + diffPanel.className = 'mnote-conflict-diff-panel'; + diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel'); + diffPanel.hidden = true; + panel.append(heading, text, meta, actions, diffPanel); + + acceptDisk.addEventListener('click', () => { + runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); + }); + keepCurrent.addEventListener('click', () => { + runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); + }); + openDiff.addEventListener('click', () => { + openConflictDiffPanel(session, panel); + }); + } + + if (runtime && typeof runtime.mountSessionConflictPanel === 'function') { + if (runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)) { + return; + } + } + const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; + if (!(host instanceof HTMLElement)) return; + const header = host.querySelector('.document-shell-header'); + if (header && header.parentNode) { + header.parentNode.insertBefore(panel, header.nextSibling); + } else { + host.prepend(panel); + } + }); + }; + + const markSessionExternalConflict = (session, message) => { + session.externalChangePending = false; + ensureSessionConflictEnvelope(session, message); + session.hasExternalConflict = true; + if (session.saveTimer) { + window.clearTimeout(session.saveTimer); + session.saveTimer = 0; + } + const nextMessage = message || externalConflictMessage; + setSessionStatus(session, 'external-change-conflict', nextMessage); + renderSessionConflictSurface(session, nextMessage); + }; + + const queueSessionSave = (session) => { + if (session.readOnly || session.hasExternalConflict) return; + if (session.saveTimer) window.clearTimeout(session.saveTimer); + setSessionStatus(session, 'dirty'); + session.saveTimer = window.setTimeout(() => { + session.saveTimer = 0; + void persistSession(session); + }, 650); + }; + + const persistSession = async (session) => { + if (session.readOnly || session.saving || session.hasExternalConflict) return; + const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; + if (hydrateView) { + hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root); + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + } + const serialized = session.currentSerialized; + if (!session.dirty && serialized === session.lastPersistedSerialized) { + setSessionStatus(session, 'saved'); + return; + } + session.saving = true; + setSessionStatus(session, 'saving'); + try { + const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument); + const content = legacyBlocksFromEditorDocument(editorDocument); + const saveEndpoint = session.saveEndpoint || '/api/documents/save'; + const savePayload = session.sessionKind === 'resource' + ? { + rootUri: session.rootUri, + path: session.resourcePath, + expectedFileVersion: session.conflictDetectionKey, + contentFormat: 'editorBlocks', + editorSource: 'tiptap-resource-tab', + editorDocument, + content, + tiptapDocument: session.currentTiptapDocument, + blockCount: editorDocument.blocks.length, + } + : { + documentId: session.documentId, + workspaceId: session.workspaceId, + sourceKind: session.sourceKind, + rootUri: session.rootUri, + revision: session.revision, + expectedFileVersion: session.conflictDetectionKey, + contentFormat: 'editorBlocks', + editorSource: 'tiptap', + editorDocument, + content, + tiptapDocument: session.currentTiptapDocument, + blockCount: editorDocument.blocks.length, + }; + if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') { + savePayload.conflictDetectionKey = session.conflictDetectionKey; + } + markLocalFolderSelfChangeSuppression(session); + const response = await fetch(saveEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(savePayload), + }); + const result = await response.json().catch(() => null); + if (!response.ok || !result || result.ok !== true) { + const message = result?.error?.message || result?.message || `save_failed_${response.status}`; + const conflictEnvelope = conflictEnvelopeFromResponse(result); + if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') { + session.saving = false; + session.lastExternalConflictEnvelope = conflictEnvelope; + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); + return; + } + throw new Error(message); + } + const saved = result.result || {}; + if (Number.isInteger(saved.revision)) session.revision = saved.revision; + if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) { + session.conflictDetectionKey = saved.conflict_detection_key.trim(); + } + if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) { + session.conflictDetectionKey = saved.conflictDetectionKey.trim(); + } + if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) { + session.conflictDetectionKey = saved.fileVersion.trim(); + } + if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + syncSessionMetaToViews(session); + session.lastPersistedSerialized = serialized; + session.saving = false; + if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') { + const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0]; + if (primaryView) { + const plainText = sessionPlainText(session); + window.__mnoteRecordPageHistorySnapshot('save', { + wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length, + characterCount: plainText.replace(/\s/g, '').length, + blockCount: editorDocument.blocks.length, + todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length, + todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length, + }); + } + } + if (session.currentSerialized === serialized) { + session.dirty = false; + setSessionStatus(session, 'saved'); + } else { + session.dirty = true; + setSessionStatus(session, 'dirty'); + queueSessionSave(session); + } + syncResourceSessionTabGuards(session); + } catch (error) { + session.saving = false; + setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error)); + syncResourceSessionTabGuards(session); + } + }; + + const scheduleSessionExternalRefresh = (session, source) => { + if (!session || session.views.size === 0) return; + if (session.externalRefreshTimer) return; + session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change'; + session.externalRefreshTimer = window.setTimeout(() => { + const refreshSource = session.externalRefreshSource || 'mnote-web-external-change'; + session.externalRefreshSource = ''; + session.externalRefreshTimer = 0; + void refreshSessionFromExternalChange(session, refreshSource); + }, 120); + }; + + const refreshSessionFromExternalChange = async (session, source) => { + if (document.hidden) return; + if (!session || session.views.size === 0) return; + if (session.sourceKind === 'local_folder' && !session.rootUri) return; + try { + const response = await fetch(pageAggregateUrl({ + documentId: session.documentId, + sourceKind: session.sourceKind, + workspaceId: session.workspaceId, + rootUri: session.rootUri, + }).toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + if (session.sourceKind === 'local_folder') { + markSessionExternalConflict(session, externalConflictMessage); + } + return; + } + const payload = await response.json(); + const conflictEnvelope = conflictEnvelopeFromResponse(payload); + if (response.status === 409 && conflictEnvelope) { + session.lastExternalConflictEnvelope = conflictEnvelope; + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); + return; + } + let nextAggregate = payload?.result; + if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) { + await delay(500); + try { + const retryAggregate = await fetchLatestSessionAggregate(session); + if (aggregatePlainText(retryAggregate)) { + nextAggregate = retryAggregate; + } else { + markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。'); + return; + } + } catch (_retryError) { + markSessionExternalConflict(session, externalConflictMessage); + return; + } + } + const nextBody = nextAggregate?.body || {}; + const nextPermissions = nextAggregate?.head?.permissions || {}; + const nextConflictKey = conflictDetectionKeyFromBody(nextBody); + const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); + const nextSerialized = JSON.stringify(nextTiptapDocument); + const contentChanged = nextSerialized !== session.currentSerialized; + session.externalChangePending = false; + if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { + session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; + if (!contentChanged) return; + } + if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return; + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + markSessionExternalConflict(session, externalConflictMessage); + return; + } + const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); + session.latestAggregate = nextAggregate; + syncPageAggregateScript(session, nextAggregate); + session.title = nextAggregate?.head?.title || session.title; + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = session.currentSerialized; + session.revision = nextRevision; + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey || ''; + session.readOnly = Boolean(nextPermissions.readOnly); + session.dirty = false; + session.hasExternalConflict = false; + session.lastUserInputAt = 0; + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change'); + }); + setSessionStatus(session, 'synced-external-change'); + } catch (error) { + console.warn('mnote 页面外部更新检测失败', error); + } + }; + + const refreshSessionFromExternalFileChange = async (session) => { + if (session.sourceKind !== 'local_folder') return; + await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); + }; + + const ensureLocalFolderEventChannel = (session) => { + if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') { + return; + } + const channelKey = localFolderEventChannelKey(session); + let channel = localFolderEventRegistry.get(channelKey); + if (!channel) { + const url = new URL('/api/local-folder/events', window.location.origin); + url.searchParams.set('rootUri', session.rootUri); + url.searchParams.set('documentId', session.documentId); + const eventSource = new EventSource(url.toString()); + channel = { + key: channelKey, + rootUri: session.rootUri, + documentId: session.documentId, + eventSource, + sessions: new Map(), + }; + eventSource.addEventListener('change', (event) => { + const payload = parseLocalFolderEventPayload(event); + if (!payload) return; + Array.from(channel.sessions.values()).forEach((targetSession) => { + if (!targetSession || targetSession.views.size === 0) return; + const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; + if (!documentId) return; + const eventKind = String(payload.eventKind || ''); + const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); + if (documentId && !targetsCurrentDocument) return; + if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + if (targetsCurrentDocument && targetSession.saving) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + targetSession.lastExternalChangeSignalAt = Date.now(); + targetSession.externalChangePending = true; + if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) { + markSessionExternalConflict(targetSession, externalConflictMessage); + return; + } + scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); + }); + }); + eventSource.onerror = () => { + console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连'); + }; + localFolderEventRegistry.set(channelKey, channel); + } + channel.sessions.set(session.key, session); + session.localFolderChannel = channel; + }; + + const readTreePayloadData = (payload) => ( + payload && typeof payload === 'object' + ? (payload.data || payload.delta || payload) + : null + ); + + const readTreePayloadOverview = (payload) => ( + payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object' + ? payload.overview + : null + ); + + const readTreePayloadCursor = (payload) => { + const raw = String(payload?.cursor || payload?.revision || '').trim(); + if (!raw) return { id: '', createdAt: '', raw: '' }; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return { + id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(), + createdAt: String(parsed.createdAt || parsed.created_at || '').trim(), + raw, + }; + } + } catch (_) {} + return { id: raw, createdAt: '', raw }; + }; + + const treeRecordMatchesPayloadCursor = (record, payload) => { + if (!record || typeof record !== 'object') return false; + const cursor = readTreePayloadCursor(payload); + if (!cursor.id && !cursor.createdAt && !cursor.raw) return false; + const ids = [ + record.id, + record._id, + record.command_log_id, + record.commandLogId, + record.domain_event_id, + record.domainEventId, + record.command_id, + record.commandId, + ].map((value) => String(value || '').trim()).filter(Boolean); + if (cursor.id && ids.includes(cursor.id)) return true; + const createdAt = String(record.created_at || record.createdAt || '').trim(); + return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt); + }; + + const treeRecordTargetsDocument = (record, documentId) => { + if (!record || typeof record !== 'object' || !documentId) return false; + const targetPageId = String(record.target_page_id || record.targetPageId || '').trim(); + const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); + if (targetPageId === documentId || aggregateId === documentId) return true; + const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; + if (!payload) return false; + const streamDelta = payload.streamDelta || payload.stream_delta || null; + const deltaDocumentId = streamDelta && typeof streamDelta === 'object' + ? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim() + : ''; + return deltaDocumentId === documentId; + }; + + const collectMindmapIdsFromTreeRecord = (record, documentId, out) => { + if (!record || typeof record !== 'object' || !documentId) return; + if (!treeRecordTargetsDocument(record, documentId)) return; + const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim(); + if (targetBlockId) out.add(targetBlockId); + const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim(); + const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); + if (aggregateType === 'block' && aggregateId) out.add(aggregateId); + const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; + const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null; + const blockId = streamDelta && typeof streamDelta === 'object' + ? String(streamDelta.blockId || streamDelta.block_id || '').trim() + : ''; + if (blockId) out.add(blockId); + }; + + const collectMindmapIdsFromTreePayload = (payload, session) => { + const ids = new Set(); + if (!payload || typeof payload !== 'object' || !session?.documentId) return []; + const kind = String(payload.kind || '').trim(); + const data = readTreePayloadData(payload); + if (kind === 'delta' && data && typeof data === 'object') { + const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); + if (!documentId || documentId === session.documentId) { + const blockId = String(data.blockId || data.block_id || '').trim(); + if (blockId) ids.add(blockId); + const streamDelta = data.streamDelta || data.stream_delta || null; + const streamBlockId = streamDelta && typeof streamDelta === 'object' + ? String(streamDelta.blockId || streamDelta.block_id || '').trim() + : ''; + if (streamBlockId) ids.add(streamBlockId); + } + } + if (kind === 'resync') { + const overview = readTreePayloadOverview(payload); + if (overview) { + const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; + const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; + commandLogs + .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) + .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); + domainEvents + .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) + .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); + } + } + return Array.from(ids); + }; + + const refreshMindmapRuntimesFromTreePayload = (payload, session) => { + const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {}; + collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => { + const bridge = registry[mindmapId]; + if (bridge && typeof bridge.refreshProjection === 'function') { + void bridge.refreshProjection('mnote-web-tree-live'); + } + }); + }; + + const treePayloadTargetsDocument = (payload, session) => { + if (!payload || typeof payload !== 'object' || !session?.documentId) return false; + if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false; + const data = readTreePayloadData(payload); + if (data && typeof data === 'object') { + const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); + if (documentId === session.documentId) return true; + const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : []; + if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true; + } + const overview = readTreePayloadOverview(payload); + if (!overview) return false; + const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; + const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; + return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId)) + || domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId)); + }; + + const handleTreeExternalChange = (event) => { + const payload = event?.detail?.payload || event?.detail || null; + if (!payload) return; + Array.from(documentSessionRegistry.values()).forEach((session) => { + if (session.sourceKind === 'local_folder') return; + if (!treePayloadTargetsDocument(payload, session)) return; + refreshMindmapRuntimesFromTreePayload(payload, session); + session.lastExternalChangeSignalAt = Date.now(); + session.externalChangePending = true; + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + markSessionExternalConflict(session, treeExternalConflictMessage); + return; + } + scheduleSessionExternalRefresh(session, 'mnote-web-tree-live'); + }); + }; + + const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => { + if (!session) return false; + const doc = String(documentId || '').trim(); + const workspace = String(workspaceId || '').trim(); + if (doc && session.documentId !== doc) return false; + if (workspace && session.workspaceId && session.workspaceId !== workspace) return false; + return true; + }; + + const refreshDocumentSessionsFromExternalWrite = (detail, source) => { + const documentId = String(detail?.documentId || '').trim(); + const workspaceId = String(detail?.workspaceId || '').trim(); + let scheduled = 0; + Array.from(documentSessionRegistry.values()).forEach((session) => { + if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return; + session.lastExternalChangeSignalAt = Date.now(); + session.externalChangePending = true; + session.lastExternalWriteSource = source || session.lastExternalWriteSource || ''; + session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim(); + session.lastExternalWriteDocumentId = documentId; + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage); + return; + } + scheduled += 1; + scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write'); + }); + return scheduled; + }; + + window.addEventListener('tree:delta', handleTreeExternalChange); + window.addEventListener('tree:resync', handleTreeExternalChange); + window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { + refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); + }); + + const createDocumentSession = (runtimeDescriptor) => { + const pageBody = runtimeDescriptor.aggregate.body || {}; + const permissions = runtimeDescriptor.aggregate.head?.permissions || {}; + const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody); + const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null; + const keyRevision = revisionFromConflictKey(conflictDetectionKey); + const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); + const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap); + const session = { + key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), + documentId: runtimeDescriptor.bootstrap.documentId, + workspaceId: runtimeDescriptor.bootstrap.workspaceId, + sourceKind, + rootUri: runtimeDescriptor.bootstrap.rootUri, + saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save', + pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__', + latestAggregate: runtimeDescriptor.aggregate, + title: runtimeDescriptor.aggregate.head?.title || '无标题', + currentTiptapDocument: tiptapDocument, + currentSerialized: JSON.stringify(tiptapDocument), + lastPersistedSerialized: JSON.stringify(tiptapDocument), + revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision, + conflictDetectionKey, + fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey, + lastExternalConflictDetectionKey: conflictDetectionKey || '', + readOnly: Boolean(permissions.readOnly), + dirty: false, + saving: false, + hasExternalConflict: false, + externalChangePending: false, + externalRefreshSource: '', + lastExternalChangeSignalAt: 0, + lastSelfSaveSignalAt: 0, + lastUserInputAt: 0, + status: 'booting', + error: null, + saveTimer: 0, + externalRefreshTimer: 0, + releaseTimer: 0, + views: new Map(), + localFolderChannel: null, + }; + ensureLocalFolderEventChannel(session); + return session; + }; + + const getOrCreateDocumentSession = (runtimeDescriptor) => { + const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap); + const existing = documentSessionRegistry.get(key); + if (existing) { + cancelDocumentSessionRelease(existing); + return existing; + } + const session = createDocumentSession(runtimeDescriptor); + documentSessionRegistry.set(key, session); + return session; + }; + + + return { + cancelDocumentSessionRelease, + broadcastSessionContent, + clearEmbeddedLocalDraft, + clearSessionConflictSurface, + currentEditorText, + dispatchSessionContentToView, + documentSessionRegistry, + externalConflictMessage, + getOrCreateDocumentSession, + markSessionExternalConflict, + queueSessionSave, + refreshSessionFromExternalChange, + releaseDocumentSession, + scheduleDocumentSessionRelease, + sessionHasRecentExternalSignal, + sessionHasRecentLocalInput, + sessionMatchesDocumentWorkspace, + sessionViews, + setSessionStatus, + }; +}; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index b8056d55..91f9354f 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -154,6 +154,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/document-resource-tab-runtime.js", get(web_shell::document_resource_tab_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/document-session-runtime.js", + get(web_shell::document_session_runtime_asset), + ) .route( "/api/mnote-browser-runtime/document-slash-position-runtime.js", get(web_shell::document_slash_position_runtime_asset), @@ -582,6 +586,7 @@ mod tests { "/api/mnote-browser-runtime/document-pane-runtime.js", "/api/mnote-browser-runtime/document-mindmap-host-runtime.js", "/api/mnote-browser-runtime/document-resource-tab-runtime.js", + "/api/mnote-browser-runtime/document-session-runtime.js", "/api/mnote-browser-runtime/document-slash-position-runtime.js", "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js", "/api/mnote-browser-runtime/document-editor-adapter-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index be2e9374..530a716e 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -897,6 +897,20 @@ pub async fn document_resource_tab_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn document_session_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/document-session-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn document_slash_position_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-slash-position-runtime.js"); Response::builder() @@ -1353,6 +1367,8 @@ mod tests { const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js"); const DOCUMENT_RESOURCE_TAB_RUNTIME_JS: &str = include_str!("../../browser/document-resource-tab-runtime.js"); + const DOCUMENT_SESSION_RUNTIME_JS: &str = + include_str!("../../browser/document-session-runtime.js"); const DOCUMENT_SLASH_POSITION_RUNTIME_JS: &str = include_str!("../../browser/document-slash-position-runtime.js"); const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str = @@ -1649,7 +1665,8 @@ mod tests { assert!(html.contains("mnote.tree_live_bootstrap.v1")); assert!(html.contains("/api/tree/events")); assert!(html.contains("/api/realtime/ws")); - assert!(runtime.contains("syncPageAggregateScript(session, nextAggregate);")); + let session_runtime = DOCUMENT_SESSION_RUNTIME_JS; + assert!(session_runtime.contains("syncPageAggregateScript(session, nextAggregate);")); let conversion_runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS; assert!(conversion_runtime.contains( "const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {" @@ -1658,17 +1675,17 @@ mod tests { assert!( conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content") ); - assert!(runtime + assert!(session_runtime .contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);")); - assert!(runtime.contains( + assert!(session_runtime.contains( "const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);" )); - assert!(runtime.contains("mnote.localFolder.selfChangeSuppressions.v1")); - assert!(runtime.contains("ensureLocalFolderSelfChangeSuppressions()")); - assert!(runtime.contains("markLocalFolderSelfChangeSuppression(session);")); - assert!(runtime.contains("const suppressibleSelfWrite = kind.includes('Create')")); - assert!(runtime.contains("|| kind.includes('Modify(Data')")); - assert!(runtime.contains("|| kind.includes('Modify(Any')")); + assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1")); + assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()")); + assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);")); + assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')")); + assert!(session_runtime.contains("|| kind.includes('Modify(Data')")); + assert!(session_runtime.contains("|| kind.includes('Modify(Any')")); assert!(!runtime.contains("mnote-web-document-shell")); // Resource open resolver contract @@ -2020,42 +2037,43 @@ mod tests { assert!(html.contains("/api/mnote-browser-runtime/filetree-runtime.js")); assert!(html.contains(r#""#)); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); - let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; - assert!(runtime.contains("refreshSessionFromExternalFileChange")); - assert!(runtime.contains("refreshSessionFromExternalChange")); - assert!(runtime.contains("treeExternalConflictMessage")); - assert!(runtime.contains("tree:delta")); - assert!(runtime.contains("tree:resync")); - assert!(runtime.contains("mnote-web-tree-live")); - assert!(runtime.contains("refreshMindmapRuntimesFromTreePayload")); - assert!(runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__")); - assert!(runtime.contains("/api/local-folder/events")); - assert!(runtime.contains("localFolderEventChannelKey")); - assert!(runtime.contains("url.searchParams.set('documentId', session.documentId);")); - assert!(runtime.contains("if (!documentId) return;")); - assert!(runtime.contains("new EventSource(url.toString())")); - assert!(runtime.contains("localFolderEventRegistry")); - assert!(runtime + let session_runtime = DOCUMENT_SESSION_RUNTIME_JS; + assert!(session_runtime.contains("refreshSessionFromExternalFileChange")); + assert!(session_runtime.contains("refreshSessionFromExternalChange")); + assert!(session_runtime.contains("treeExternalConflictMessage")); + assert!(session_runtime.contains("tree:delta")); + assert!(session_runtime.contains("tree:resync")); + assert!(session_runtime.contains("mnote-web-tree-live")); + assert!(session_runtime.contains("refreshMindmapRuntimesFromTreePayload")); + assert!(session_runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__")); + assert!(session_runtime.contains("/api/local-folder/events")); + assert!(session_runtime.contains("localFolderEventChannelKey")); + assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);")); + assert!(session_runtime.contains("if (!documentId) return;")); + assert!(session_runtime.contains("new EventSource(url.toString())")); + assert!(session_runtime.contains("localFolderEventRegistry")); + assert!(session_runtime .contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')")); - assert!(runtime.contains("shouldSuppressLocalFolderSelfChange")); - assert!(runtime.contains("kind.includes('Modify(Name')")); - assert!(runtime.contains("targetSession.views.size === 0")); - assert!(runtime.contains("session.views.size === 0")); - assert!(runtime.contains("targetSession.saving")); - assert!(runtime.contains("lastSelfSaveSignalAt")); - assert!(runtime.contains("clearSessionConflictSurface(view.session);")); - assert!(runtime.contains("command: 'replaceContent'")); - assert!(runtime.contains("external-change-conflict")); - assert!(runtime.contains("mnote-editor-conflict-panel")); - assert!(runtime.contains("mnote-conflict-accept-disk")); - assert!(runtime.contains("mnote-conflict-keep-current")); - assert!(runtime.contains("mnote-conflict-open-diff")); - assert!(runtime.contains("mnote-conflict-merge-text")); - assert!(runtime.contains("mnote-conflict-merge-save")); - assert!(runtime + assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange")); + assert!(session_runtime.contains("kind.includes('Modify(Name')")); + assert!(session_runtime.contains("targetSession.views.size === 0")); + assert!(session_runtime.contains("session.views.size === 0")); + assert!(session_runtime.contains("targetSession.saving")); + assert!(session_runtime.contains("lastSelfSaveSignalAt")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("clearSessionConflictSurface(view.session);")); + assert!(session_runtime.contains("command: 'replaceContent'")); + assert!(session_runtime.contains("external-change-conflict")); + assert!(session_runtime.contains("mnote-editor-conflict-panel")); + assert!(session_runtime.contains("mnote-conflict-accept-disk")); + assert!(session_runtime.contains("mnote-conflict-keep-current")); + assert!(session_runtime.contains("mnote-conflict-open-diff")); + assert!(session_runtime.contains("mnote-conflict-merge-text")); + assert!(session_runtime.contains("mnote-conflict-merge-save")); + assert!(session_runtime .contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)")); - assert!(runtime.contains("agent run")); - assert!(!runtime.contains( + assert!(session_runtime.contains("agent run")); + assert!(!session_runtime.contains( "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" )); } @@ -2120,6 +2138,10 @@ mod tests { assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("openResourceInActiveTab")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("buildOpenEditorsSnapshot")); assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("bindMainEditorTabStrip")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-session-runtime.js")); + assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime")); + assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession")); + assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js")); assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition")); assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition")); @@ -2287,7 +2309,7 @@ mod tests { assert!(runtime.contains("typeof payload.text === 'string'")); assert!(runtime.contains("payload.type === 'hard_break'")); assert!(runtime.contains("typeof body?.fileVersion === 'string'")); - assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + assert!(DOCUMENT_SESSION_RUNTIME_JS .contains("expectedFileVersion: session.conflictDetectionKey")); assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'")); assert!(runtime.contains("blockType: 'mindmap'"));