diff options
| author | haoyuren <13851610112@163.com> | 2026-03-15 18:21:06 -0500 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-03-15 18:21:06 -0500 |
| commit | c9d673d83037167553dcef3947065266743b2d5f (patch) | |
| tree | aa4d5c54da8db9a5d05052fa0b6771f0dbc7e6ee /src/renderer | |
| parent | 90abc457f29f110dbf89f98efef5d9743efee963 (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'src/renderer')
| -rw-r--r-- | src/renderer/src/App.tsx | 26 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 9 | ||||
| -rw-r--r-- | src/renderer/src/ot/overleafSync.ts | 35 |
3 files changed, 63 insertions, 7 deletions
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 |
