From c9d673d83037167553dcef3947065266743b2d5f Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Sun, 15 Mar 2026 18:21:06 -0500 Subject: Fix file sync for non-active tabs, MCP compile integration, OT resilience - Fix .bib (and other non-active tab) edits disappearing: call otLeaveDoc on tab switch so bridge takes back OT ownership; release .bib pre-loads immediately after reading content for citation autocomplete - Always update lastKnownContent in processDocChange for editor docs to prevent stale state accumulation - Flush pending OT ops in OverleafDocSync.destroy() before tab switch - Add three-way merge in replaceContent to preserve concurrent remote edits - Wire MCP compile to UI: file-based signal between MCP server and Electron main process, with compile animation and PDF refresh in renderer - Add CLSI flush before compile to prevent stale cached results - Add OT error recovery: re-join doc and re-apply disk changes on otUpdateError - Add bridge reconnect handling: reset OtClient on docRejoined for non-editor docs - Add compile concurrency lock to prevent duplicate compiles - removeEditorDoc compares disk vs server content to catch in-flight ops Co-Authored-By: Claude Opus 4.6 --- src/renderer/src/App.tsx | 26 ++++++++++++++++++++++++- src/renderer/src/components/Editor.tsx | 9 ++++++++- src/renderer/src/ot/overleafSync.ts | 35 +++++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 7 deletions(-) (limited to 'src/renderer') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 63be974..cb14123 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -103,7 +103,7 @@ export default function App() { // Listen for external edits from file sync bridge (disk changes) const unsubExternalEdit = window.api.onSyncExternalEdit((data) => { const sync = activeDocSyncs.get(data.docId) - if (sync) sync.replaceContent(data.content) + if (sync) sync.replaceContent(data.content, data.baseContent) }) // Listen for new docs created locally (e.g. by Claude Code) @@ -195,6 +195,25 @@ export default function App() { return unsub }, []) + // MCP compile events (Claude Code triggered compile) + useEffect(() => { + const unsubStart = window.api.onMcpCompileStarted(() => { + const state = useAppStore.getState() + state.setCompiling(true) + state.clearCompileLog() + setStatusMessage('Compiling (triggered by Claude Code)...') + }) + const unsubFinish = window.api.onMcpCompileFinished((data) => { + const state = useAppStore.getState() + if (data.pdfPath) { + state.setPdfPath(data.pdfPath) + } + state.setCompiling(false) + setStatusMessage(data.success ? 'Compiled successfully' : 'Compilation had errors — check Log tab') + }) + return () => { unsubStart(); unsubFinish() } + }, [setStatusMessage]) + // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -340,6 +359,11 @@ export default function App() { useAppStore.getState().setDocVersion(docId, res.version) } } + // Release back to bridge — we only needed the content for autocomplete, + // not an active editor session. Without this, the bridge permanently thinks + // the renderer handles OT for .bib files and defers disk changes to a + // non-existent docSync. + window.api.otLeaveDoc(docId) }).catch(() => {}) } } diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index d3a1e3f..97adf1b 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -300,7 +300,14 @@ export default function Editor() { return () => { if (docSyncRef.current) { const docId = pathDocMap[activeTab!] - if (docId) activeDocSyncs.delete(docId) + if (docId) { + activeDocSyncs.delete(docId) + // Tell bridge to take back OT ownership for this doc. + // Without this, the bridge thinks the renderer still handles it + // and defers disk-change processing — but the docSync is about to + // be destroyed, so disk changes would be silently dropped. + window.api.otLeaveDoc(docId) + } docSyncRef.current.destroy() docSyncRef.current = null } diff --git a/src/renderer/src/ot/overleafSync.ts b/src/renderer/src/ot/overleafSync.ts index e9cb749..4a6deda 100644 --- a/src/renderer/src/ot/overleafSync.ts +++ b/src/renderer/src/ot/overleafSync.ts @@ -125,16 +125,33 @@ export class OverleafDocSync { } } - /** Replace entire editor content with new content (external edit from disk) */ - replaceContent(newContent: string) { + /** Replace entire editor content with new content (external edit from disk). + * If baseContent is provided, does a three-way merge to preserve concurrent + * remote changes that arrived while the disk edit was being debounced. */ + replaceContent(newContent: string, baseContent?: string) { if (!this.view) return const currentContent = this.view.state.doc.toString() if (currentContent === newContent) return - // Use diff to compute minimal changes so comment range positions remap correctly const dmp = new diff_match_patch() - const diffs = dmp.diff_main(currentContent, newContent) + let targetContent = newContent + + // Three-way merge: if editor has diverged from the base (due to remote edits), + // apply only the disk changes (base→new) as patches on top of current editor state + if (baseContent !== undefined && currentContent !== baseContent) { + const patches = dmp.patch_make(baseContent, newContent) + const [merged, results] = dmp.patch_apply(patches, currentContent) + if (results.length > 0 && results.every(r => r)) { + targetContent = merged + } + // If patch failed, fall through to two-way diff (full replacement) + } + + if (currentContent === targetContent) return + + // Use diff to compute minimal changes so comment range positions remap correctly + const diffs = dmp.diff_main(currentContent, targetContent) dmp.diff_cleanupEfficiency(diffs) const changes: ChangeSpec[] = [] @@ -156,7 +173,15 @@ export class OverleafDocSync { } destroy() { - if (this.debounceTimer) clearTimeout(this.debounceTimer) + // Flush any debounced local changes before destroying, so OT ops are sent + // to the server before the bridge takes back ownership of this doc. + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + if (this.pendingChanges && this.view && this.pendingBaseDoc) { + this.flushLocalChanges() + } this.view = null this.pendingChanges = null this.pendingBaseDoc = null -- cgit v1.2.3