From 9b5256718c2117511f0253a656bb8cff7410b92a Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Wed, 18 Mar 2026 08:06:32 +0000 Subject: Fix OT sync corruption: match Overleaf ShareJS ack/echo handling The server broadcasts otUpdateApplied (with ops) to ALL clients including the sender. Our bridge was treating its own echoed ops as remote ops and re-applying them, causing text duplication (e.g. "simulatorimulator"). Rewrite OT handling to match Overleaf's ShareJS _onMessage pattern: - ACK = no ops OR meta.source matches our publicId (own echo) - REMOTE = ops from a different source - ACK path calls onAck() without re-applying ops - OtClient silently drops duplicate acks in synchronized state - OtClient drops stale remote ops (version < current) - Remove pendingEchos counter in favor of meta.source detection Also: refresh MCP comment contexts on new-comment/delete-thread events, add Overleaf reference repo to .gitignore. Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) (limited to 'src/main/index.ts') diff --git a/src/main/index.ts b/src/main/index.ts index 5efdae5..d2c3d64 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,6 +47,31 @@ async function writeMcpState(): Promise { } catch { /* ignore */ } } +let commentContextRefreshTimer: ReturnType | null = null +function scheduleCommentContextRefresh(): void { + if (commentContextRefreshTimer) clearTimeout(commentContextRefreshTimer) + commentContextRefreshTimer = setTimeout(async () => { + commentContextRefreshTimer = null + if (!overleafSock?.projectData) return + const { docPathMap: dp } = walkRootFolder(overleafSock.projectData.project.rootFolder) + const contexts: Record = {} + for (const [did, rp] of Object.entries(dp)) { + try { + const result = await overleafSock.joinDoc(did) + if (result.ranges?.comments) { + for (const c of result.ranges.comments) { + if (c.op?.t) contexts[c.op.t] = { file: rp, text: c.op.c || '', pos: c.op.p || 0 } + } + } + // Don't leaveDoc — bridge keeps all docs joined + } catch { /* ignore */ } + } + mcpCommentContexts = contexts + writeMcpState() + sendToRenderer('comments:initContexts', { contexts }) + }, 2000) // 2s debounce +} + function writeMcpOnlineUsers(): void { if (!mcpStateDir) return if (mcpOnlineUsersWriteTimer) clearTimeout(mcpOnlineUsersWriteTimer) @@ -776,6 +801,10 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { name === 'delete-message' ) { sendToRenderer('comments:event', { type: name, args }) + // Re-fetch comment contexts for MCP when comments change + if (name === 'new-comment' || name === 'delete-thread') { + scheduleCommentContextRefresh() + } } }) @@ -968,14 +997,13 @@ The \`claude-workspace/\` directory is your private scratch space. It is **not s const contexts: Record = {} for (const [did, rp] of Object.entries(dp)) { try { - const alreadyJoined = docEventHandlers.has(did) const result = await overleafSock.joinDoc(did) if (result.ranges?.comments) { for (const c of result.ranges.comments) { if (c.op?.t) contexts[c.op.t] = { file: rp, text: c.op.c || '', pos: c.op.p || 0 } } } - if (!alreadyJoined) await overleafSock.leaveDoc(did) + // Don't leaveDoc — bridge keeps all docs joined } catch { /* ignore */ } } mcpCommentContexts = contexts -- cgit v1.2.3