summaryrefslogtreecommitdiff
path: root/src/main/fileSyncBridge.ts
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/main/fileSyncBridge.ts
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/main/fileSyncBridge.ts')
-rw-r--r--src/main/fileSyncBridge.ts124
1 files changed, 114 insertions, 10 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)
})