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/main/fileSyncBridge.ts | 124 ++++++++++++++++++++++++++++++--- src/main/index.ts | 116 ++++++++++++++++++++++++++++-- src/mcp/lattex.mjs | 65 +++++++++++++++-- src/preload/index.ts | 16 ++++- src/renderer/src/App.tsx | 26 ++++++- src/renderer/src/components/Editor.tsx | 9 ++- src/renderer/src/ot/overleafSync.ts | 35 ++++++++-- 7 files changed, 362 insertions(+), 29 deletions(-) diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts index 73f3e70..f17808c 100644 --- a/src/main/fileSyncBridge.ts +++ b/src/main/fileSyncBridge.ts @@ -59,6 +59,7 @@ export class FileSyncBridge { private csrfToken: string private serverEventHandler: ((name: string, args: unknown[]) => void) | null = null + private docRejoinedHandler: ((docId: string, result: { docLines: string[]; version: number }) => void) | null = null private stopped = false constructor( @@ -115,6 +116,8 @@ export class FileSyncBridge { this.serverEventHandler = (name: string, args: unknown[]) => { if (name === 'otUpdateApplied') { this.handleOtUpdate(args) + } else if (name === 'otUpdateError') { + this.handleOtError(args) } else if (name === 'reciveNewFile') { this.handleNewFile(args) } else if (name === 'reciveNewDoc') { @@ -127,6 +130,27 @@ export class FileSyncBridge { } this.socket.on('serverEvent', this.serverEventHandler) + // Listen for doc rejoin events (after reconnect) — reset bridge OtClient for non-editor docs + this.docRejoinedHandler = (docId: string, result: { docLines: string[]; version: number }) => { + if (this.editorDocs.has(docId)) return // renderer handles editor docs + const relPath = this.docPathMap[docId] + if (!relPath) return + + const content = (result.docLines || []).join('\n') + bridgeLog(`[FileSyncBridge] docRejoined: resetting ${relPath} to v${result.version}`) + this.lastKnownContent.set(relPath, content) + + const otClient = new OtClient( + result.version, + (ops, version) => this.sendOps(docId, ops, version), + (ops) => this.onRemoteApply(docId, ops) + ) + this.otClients.set(docId, otClient) + + this.writeToDisk(relPath, content) + } + this.socket.on('docRejoined', this.docRejoinedHandler) + // Start watching the temp dir // usePolling: FSEvents is unreliable in macOS temp dirs (/var/folders/...) // atomic: Claude Code and other editors use atomic writes (write temp + rename) @@ -184,11 +208,15 @@ export class FileSyncBridge { } this.debounceTimers.clear() - // Remove server event handler + // Remove event handlers if (this.serverEventHandler) { this.socket.removeListener('serverEvent', this.serverEventHandler) this.serverEventHandler = null } + if (this.docRejoinedHandler) { + this.socket.removeListener('docRejoined', this.docRejoinedHandler) + this.docRejoinedHandler = null + } // Close watcher if (this.watcher) { @@ -262,6 +290,58 @@ export class FileSyncBridge { } } + // ── OT error handler ──────────────────────────────────────── + + /** Server rejected our OT update — recover by re-joining the doc */ + private handleOtError(args: unknown[]): void { + const error = args[0] as { doc?: string; message?: string } | undefined + if (!error?.doc) return + const docId = error.doc + if (this.editorDocs.has(docId)) return // renderer handles editor docs + + const relPath = this.docPathMap[docId] + if (!relPath) return + + bridgeLog(`[FileSyncBridge] otUpdateError for ${relPath}: ${error.message || 'unknown'}`) + + // Re-join the doc to get fresh version and content, then re-apply disk content if different + this.socket.joinDoc(docId).then(async (result) => { + const serverContent = (result.docLines || []).join('\n') + + // Reset OtClient with fresh version + const otClient = new OtClient( + result.version, + (ops, version) => this.sendOps(docId, ops, version), + (ops) => this.onRemoteApply(docId, ops) + ) + this.otClients.set(docId, otClient) + + // Check if disk has changes that need to be re-sent + let diskContent: string | undefined + try { + diskContent = await readFile(join(this.tmpDir, relPath), 'utf-8') + } catch { /* file may not exist */ } + + if (diskContent && diskContent !== serverContent) { + // Re-apply disk changes with fresh OT state + bridgeLog(`[FileSyncBridge] re-applying disk changes for ${relPath} after OT error`) + this.lastKnownContent.set(relPath, serverContent) + const diffs = dmp.diff_main(serverContent, diskContent) + dmp.diff_cleanupEfficiency(diffs) + const ops = diffsToOtOps(diffs) + if (ops.length > 0) { + this.lastKnownContent.set(relPath, diskContent) + otClient.onLocalOps(ops) + } + } else { + this.lastKnownContent.set(relPath, serverContent) + this.writeToDisk(relPath, serverContent) + } + }).catch((e) => { + bridgeLog(`[FileSyncBridge] failed to recover from OT error for ${relPath}:`, e) + }) + } + // ── Binary file event handlers (socket) ──────────────────── /** Remote: new file added to project */ @@ -508,12 +588,15 @@ export class FileSyncBridge { bridgeLog(`[FileSyncBridge] disk change detected: ${relPath} (${newContent.length} chars, was ${lastKnown?.length ?? 'undefined'})`) if (this.editorDocs.has(docId)) { - // Doc is open in editor → send to renderer via IPC - // Don't update lastKnownContent here — let the renderer confirm via syncContentChanged. - // This prevents race conditions where remote OT ops overwrite lastKnownContent - // before the disk change is fully processed through the editor's OT pipeline. + // Doc is open in editor → send to renderer via IPC. + // Include baseContent so renderer can do a three-way merge: if remote edits + // arrived during the debounce window, they'll be preserved alongside the disk edit. + // Always update lastKnownContent to match disk — even if the renderer can't process + // the edit (e.g. doc is an editor doc but not the active tab), we must not let + // lastKnownContent go stale or we'll re-detect the same "change" indefinitely. + this.lastKnownContent.set(relPath, newContent) bridgeLog(`[FileSyncBridge] → sending sync:externalEdit to renderer for ${relPath}`) - this.mainWindow.webContents.send('sync:externalEdit', { docId, content: newContent }) + this.mainWindow.webContents.send('sync:externalEdit', { docId, content: newContent, baseContent: lastKnown ?? '' }) } else { // Doc NOT open in editor → bridge handles OT directly const oldContent = lastKnown ?? '' @@ -754,9 +837,8 @@ export class FileSyncBridge { const relPath = this.docPathMap[docId] if (!relPath) return - this.socket.joinDoc(docId).then((result) => { - const content = (result.docLines || []).join('\n') - this.lastKnownContent.set(relPath, content) + this.socket.joinDoc(docId).then(async (result) => { + const serverContent = (result.docLines || []).join('\n') const otClient = new OtClient( result.version, @@ -765,7 +847,29 @@ export class FileSyncBridge { ) this.otClients.set(docId, otClient) - this.writeToDisk(relPath, content) + // Read disk content — it may be newer than server if the renderer just + // flushed OT ops that haven't been acknowledged yet (race condition). + let diskContent: string | undefined + try { + diskContent = await readFile(join(this.tmpDir, relPath), 'utf-8') + } catch { /* file may not exist */ } + + if (diskContent !== undefined && diskContent !== serverContent) { + // Disk has changes server doesn't know about — re-send as OT ops + bridgeLog(`[FileSyncBridge] removeEditorDoc: disk differs from server for ${relPath}, re-sending`) + this.lastKnownContent.set(relPath, diskContent) + const diffs = dmp.diff_main(serverContent, diskContent) + dmp.diff_cleanupEfficiency(diffs) + const ops = diffsToOtOps(diffs) + if (ops.length > 0) { + otClient.onLocalOps(ops) + } + } else { + this.lastKnownContent.set(relPath, serverContent) + if (diskContent !== serverContent) { + this.writeToDisk(relPath, serverContent) + } + } }).catch((e) => { bridgeLog(`[FileSyncBridge] failed to re-join doc ${relPath}:`, e) }) diff --git a/src/main/index.ts b/src/main/index.ts index 78a7604..5d29897 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -745,6 +745,9 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { fileSyncBridge = new FileSyncBridge(overleafSock, tmpDir, docPathMap, pathDocMap, fileRefs, mainWindow!, projectId, overleafSessionCookie, overleafCsrfToken) await fileSyncBridge.start() + // Start MCP compile watcher (detects compile requests from Claude Code) + startMcpCompileWatcher(tmpDir) + // Write MCP state + config for Claude Code integration mcpStateDir = tmpDir mcpProjectId = projectId @@ -926,7 +929,8 @@ You have MCP tools to interact with Overleaf. Use them proactively. }) ipcMain.handle('ot:disconnect', async () => { - // Clean up MCP state file + // Clean up MCP state file + compile watcher + stopMcpCompileWatcher() if (mcpStateDir) { unlink(join(mcpStateDir, '.lattex-mcp.json')).catch(() => {}) } @@ -1347,8 +1351,26 @@ ipcMain.handle('overleaf:socketCompile', async (_e, mainTexRelPath: string) => { }) }) -// Server-side compile via Overleaf's CLSI -ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => { +// Server-side compile via Overleaf's CLSI (shared by IPC handler + MCP compile watcher) +let compileInProgress: Promise<{ success: boolean; log: string; pdfPath: string }> | null = null + +async function doServerCompile(rootDocId?: string): Promise<{ success: boolean; log: string; pdfPath: string }> { + // Prevent concurrent compiles — wait for existing one if already in progress + if (compileInProgress) { + console.log('[compile] compile already in progress, waiting...') + return compileInProgress + } + + const promise = doServerCompileImpl(rootDocId) + compileInProgress = promise + try { + return await promise + } finally { + compileInProgress = null + } +} + +async function doServerCompileImpl(rootDocId?: string): Promise<{ success: boolean; log: string; pdfPath: string }> { if (!overleafSessionCookie || !overleafSock?.projectData) { return { success: false, log: 'Not connected', pdfPath: '' } } @@ -1366,6 +1388,13 @@ ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => { try { sendToRenderer('latex:log', 'Compiling on Overleaf server...\n') + // Flush in-memory OT changes to database so CLSI sees latest content + try { + await overleafFetch(`/project/${projectId}/flush`, { method: 'POST' }) + } catch (e) { + console.log('[compile] flush failed (non-fatal):', e) + } + const compileBody = JSON.stringify({ rootDoc_id: effectiveRootDocId, ...(rootResourcePath && { rootResourcePath }), @@ -1415,7 +1444,10 @@ ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => { if (logFile) { try { const logContent = await fetchBinary(buildOutputUrl(logFile), overleafSessionCookie) - sendToRenderer('latex:log', Buffer.from(logContent).toString('utf-8')) + const logText = Buffer.from(logContent).toString('utf-8') + sendToRenderer('latex:log', logText) + // Write log for MCP server to read (avoids redundant compile API call) + writeFile(join(syncDir, '.lattex-compile-log'), logText).catch(() => {}) } catch (e) { sendToRenderer('latex:log', `[log fetch failed: ${e}]\n`) } @@ -1485,8 +1517,83 @@ ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => { sendToRenderer('latex:log', msg + '\n') return { success: false, log: msg, pdfPath: '' } } +} + +ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => { + return doServerCompile(rootDocId) }) +// Watch for MCP compile requests (file-based signal from MCP server process) +let mcpCompileWatcher: ReturnType | null = null +let mcpCompileActive = false + +function startMcpCompileWatcher(syncDir: string) { + const requestPath = join(syncDir, '.lattex-compile-request') + const resultPath = join(syncDir, '.lattex-compile-result') + + // Poll for the request file every 300ms + const { watchFile, unwatchFile } = require('fs') + watchFile(requestPath, { interval: 300 }, async (curr: { size: number }) => { + if (curr.size === 0 || mcpCompileActive) return + mcpCompileActive = true + + try { + const reqData = JSON.parse(await readFile(requestPath, 'utf-8')) + await unlink(requestPath).catch(() => {}) + + console.log('[mcp-compile] compile request received:', reqData.requestId) + + // Notify renderer: compile started + sendToRenderer('compile:mcpStarted', null) + + // Resolve main_file to rootDocId if provided + let rootDocId: string | undefined + if (reqData.mainFile && mcpPathDocMap[reqData.mainFile]) { + rootDocId = mcpPathDocMap[reqData.mainFile] + } + + const result = await doServerCompile(rootDocId) + + // Notify renderer: compile finished (renderer will update PDF + compiling state) + sendToRenderer('compile:mcpFinished', { + success: result.success, + pdfPath: result.pdfPath + }) + + // Write result for MCP server to read + await writeFile(resultPath, JSON.stringify({ + requestId: reqData.requestId, + success: result.success, + pdfPath: result.pdfPath, + status: result.success ? 'success' : 'failure' + })) + console.log('[mcp-compile] compile result written:', result.success) + } catch (e) { + console.log('[mcp-compile] error handling compile request:', e) + // Write error result so MCP doesn't hang + await writeFile(resultPath, JSON.stringify({ + success: false, + status: 'error', + error: String(e) + })).catch(() => {}) + sendToRenderer('compile:mcpFinished', { success: false, pdfPath: '' }) + } finally { + mcpCompileActive = false + } + }) + + mcpCompileWatcher = { requestPath } as any + console.log('[mcp-compile] watcher started for', requestPath) +} + +function stopMcpCompileWatcher() { + if (mcpCompileWatcher) { + const { unwatchFile } = require('fs') + unwatchFile((mcpCompileWatcher as any).requestPath) + mcpCompileWatcher = null + } +} + /** Fetch a binary resource. Cookie is optional — CDN URLs use build ID for auth. */ function fetchBinary(url: string, cookie?: string): Promise { return new Promise((resolve, reject) => { @@ -1539,6 +1646,7 @@ app.whenReady().then(async () => { app.on('window-all-closed', () => { mainWindow = null + stopMcpCompileWatcher() for (const inst of ptyInstances.values()) inst.kill() ptyInstances.clear() fileSyncBridge?.stop() diff --git a/src/mcp/lattex.mjs b/src/mcp/lattex.mjs index 356122e..861ff40 100644 --- a/src/mcp/lattex.mjs +++ b/src/mcp/lattex.mjs @@ -12,7 +12,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' -import { readFileSync, readdirSync, statSync } from 'fs' +import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, unlinkSync } from 'fs' import { join, relative } from 'path' import https from 'https' @@ -277,6 +277,11 @@ function buildOutputUrl(file, data) { // ── Compile + fetch log helper ────────────────────────────── async function compileAndFetchLog(projectId, cookie, csrf, pathDocMap, mainFile) { + // Flush in-memory OT changes to database so CLSI sees latest content + try { + await overleafRequest('POST', `/project/${projectId}/flush`, cookie, csrf) + } catch {} + const body = { check: 'silent', draft: false, @@ -712,11 +717,59 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case 'compile_latex': { const mainFile = args?.main_file || null + const cwd = process.cwd() + const requestPath = join(cwd, '.lattex-compile-request') + const resultPath = join(cwd, '.lattex-compile-result') + + // Clean up any stale result file + try { unlinkSync(resultPath) } catch {} + + // Write compile request for the main process to pick up + const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + writeFileSync(requestPath, JSON.stringify({ + requestId, + mainFile, + timestamp: Date.now() + })) + + // Poll for result (main process compiles + downloads PDF + updates UI) + const timeout = 120000 // 2 minutes max + const pollInterval = 500 + const start = Date.now() + let result = null + + while (Date.now() - start < timeout) { + await new Promise(r => setTimeout(r, pollInterval)) + try { + if (existsSync(resultPath)) { + result = JSON.parse(readFileSync(resultPath, 'utf-8')) + unlinkSync(resultPath) + break + } + } catch {} + } - const { status } = await compileAndFetchLog(projectId, cookie, csrf, pathDocMap, mainFile) + if (!result) { + // Timeout — fall back to direct compile + try { unlinkSync(requestPath) } catch {} + const { status } = await compileAndFetchLog(projectId, cookie, csrf, pathDocMap, mainFile) + lastCompileStatus = status + if (status === 'success') { + return textResult('Compilation successful (direct, UI may not have updated).') + } + return textResult(`Compilation failed (status: ${status}). Use get_compile_log for details.`) + } + + // Read compile log written by main process (avoids redundant compile API call) + lastCompileStatus = result.status || (result.success ? 'success' : 'failure') + const logPath = join(cwd, '.lattex-compile-log') + try { + lastCompileLog = readFileSync(logPath, 'utf-8') + } catch { + lastCompileLog = null + } - if (status === 'success') { - // Parse warnings for summary + if (result.success) { if (lastCompileLog) { const entries = parseCompileLog(lastCompileLog) const warnings = entries.filter(e => e.level === 'warning') @@ -735,7 +788,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const errors = entries.filter(e => e.level === 'error') const warnings = entries.filter(e => e.level === 'warning') - const summary = [`Compilation failed (status: ${status}).`] + const summary = [`Compilation failed (status: ${result.status || 'failure'}).`] if (errors.length > 0) { summary.push(`\n${errors.length} error(s):`) for (const e of errors.slice(0, 10)) { @@ -752,7 +805,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return textResult(summary.join('\n')) } - return textResult(`Compilation failed with status: ${status}. No log available.`) + return textResult(`Compilation failed (status: ${result.status || 'failure'}). No log available.`) } case 'get_compile_errors': { diff --git a/src/preload/index.ts b/src/preload/index.ts index 6bdcdbb..ea330c5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -160,13 +160,25 @@ const api = { sha1: (text: string): string => createHash('sha1').update(text).digest('hex'), // File sync bridge - onSyncExternalEdit: (cb: (data: { docId: string; content: string }) => void) => { - const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: string }) => cb(data) + onSyncExternalEdit: (cb: (data: { docId: string; content: string; baseContent?: string }) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: string; baseContent?: string }) => cb(data) ipcRenderer.on('sync:externalEdit', handler) return () => ipcRenderer.removeListener('sync:externalEdit', handler) }, syncContentChanged: (docId: string, content: string) => ipcRenderer.invoke('sync:contentChanged', docId, content), + + // MCP compile events (Claude Code triggers compile via file signal) + onMcpCompileStarted: (cb: () => void) => { + const handler = () => cb() + ipcRenderer.on('compile:mcpStarted', handler) + return () => ipcRenderer.removeListener('compile:mcpStarted', handler) + }, + onMcpCompileFinished: (cb: (data: { success: boolean; pdfPath: string }) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { success: boolean; pdfPath: string }) => cb(data) + ipcRenderer.on('compile:mcpFinished', handler) + return () => ipcRenderer.removeListener('compile:mcpFinished', handler) + }, onSyncNewDoc: (cb: (data: { docId: string | null; relPath: string }) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: { docId: string | null; relPath: string }) => cb(data) ipcRenderer.on('sync:newDoc', handler) 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