summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-15 18:21:06 -0500
committerhaoyuren <13851610112@163.com>2026-03-15 18:21:06 -0500
commitc9d673d83037167553dcef3947065266743b2d5f (patch)
treeaa4d5c54da8db9a5d05052fa0b6771f0dbc7e6ee /src
parent90abc457f29f110dbf89f98efef5d9743efee963 (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')
-rw-r--r--src/main/fileSyncBridge.ts124
-rw-r--r--src/main/index.ts116
-rw-r--r--src/mcp/lattex.mjs65
-rw-r--r--src/preload/index.ts16
-rw-r--r--src/renderer/src/App.tsx26
-rw-r--r--src/renderer/src/components/Editor.tsx9
-rw-r--r--src/renderer/src/ot/overleafSync.ts35
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<typeof import('fs').watchFile> | 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<ArrayBuffer> {
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