diff options
| author | haoyuren <13851610112@163.com> | 2026-03-12 17:52:53 -0500 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-03-12 17:52:53 -0500 |
| commit | b116335f9dbde4f483c0b2b8e7bfca5d321c5dfc (patch) | |
| tree | 8bd84b0f4a54eb879c8cc5a158002e999b23d57e /src/main/fileSyncBridge.ts | |
| parent | ebec1a1073f9cc5b69e125d5b284669545ea3d9f (diff) | |
Add bidirectional file sync, OT system, comments, and real-time collaboration
Implement full Overleaf integration with Socket.IO v0.9 real-time sync:
- FileSyncBridge for bidirectional temp dir ↔ Overleaf sync via chokidar + diff-match-patch
- OT state machine, transform functions, and CM6 adapter for collaborative editing
- Comment system with highlights, tooltips, and review panel
- Project list, file tree management, and socket-based compilation
- 3-layer loop prevention (write guards, content equality, debounce)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src/main/fileSyncBridge.ts')
| -rw-r--r-- | src/main/fileSyncBridge.ts | 364 |
1 files changed, 364 insertions, 0 deletions
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts new file mode 100644 index 0000000..e0529cb --- /dev/null +++ b/src/main/fileSyncBridge.ts @@ -0,0 +1,364 @@ +// Bidirectional file sync bridge: temp dir ↔ Overleaf via OT +import { join } from 'path' +import { readFile, writeFile, mkdir } from 'fs/promises' +import { createHash } from 'crypto' +import * as chokidar from 'chokidar' +import { diff_match_patch } from 'diff-match-patch' +import type { BrowserWindow } from 'electron' +import type { OverleafSocket } from './overleafSocket' +import { OtClient } from './otClient' +import type { OtOp } from './otTypes' +import { isInsert, isDelete } from './otTypes' + +const dmp = new diff_match_patch() + +export class FileSyncBridge { + private lastKnownContent = new Map<string, string>() // relPath → content + private writesInProgress = new Set<string>() // relPaths being written by bridge + private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>() + private otClients = new Map<string, OtClient>() // docId → OtClient (non-editor docs) + private editorDocs = new Set<string>() // docIds owned by renderer + private watcher: chokidar.FSWatcher | null = null + + private socket: OverleafSocket + private tmpDir: string + private docPathMap: Record<string, string> // docId → relPath + private pathDocMap: Record<string, string> // relPath → docId + private mainWindow: BrowserWindow + + private serverEventHandler: ((name: string, args: unknown[]) => void) | null = null + private stopped = false + + constructor( + socket: OverleafSocket, + tmpDir: string, + docPathMap: Record<string, string>, + pathDocMap: Record<string, string>, + mainWindow: BrowserWindow + ) { + this.socket = socket + this.tmpDir = tmpDir + this.docPathMap = docPathMap + this.pathDocMap = pathDocMap + this.mainWindow = mainWindow + } + + async start(): Promise<void> { + // Join ALL docs, fetch content, write to disk + await mkdir(this.tmpDir, { recursive: true }) + + const docIds = Object.keys(this.docPathMap) + for (const docId of docIds) { + const relPath = this.docPathMap[docId] + try { + const result = await this.socket.joinDoc(docId) + const content = (result.docLines || []).join('\n') + this.lastKnownContent.set(relPath, content) + + // Create OtClient for this doc (bridge owns it initially) + const otClient = new OtClient( + result.version, + (ops, version) => this.sendOps(docId, ops, version), + (ops) => this.onRemoteApply(docId, ops) + ) + this.otClients.set(docId, otClient) + + // Write to disk + await this.writeToDisk(relPath, content) + } catch (e) { + console.log(`[FileSyncBridge] failed to join doc ${relPath}:`, e) + } + } + + // Listen for server events (remote ops on non-editor docs) + this.serverEventHandler = (name: string, args: unknown[]) => { + if (name === 'otUpdateApplied') { + const update = args[0] as { doc?: string; op?: OtOp[]; v?: number } | undefined + if (!update?.doc) return + const docId = update.doc + + // For non-editor docs, process remote ops through bridge's OtClient + if (!this.editorDocs.has(docId) && update.op && update.v !== undefined) { + const otClient = this.otClients.get(docId) + if (otClient) { + otClient.onRemoteOps(update.op, update.v) + } + } + + // For non-editor docs, handle ack (op with no ops array = ack for our own op) + if (!this.editorDocs.has(docId) && !update.op) { + const otClient = this.otClients.get(docId) + if (otClient) { + otClient.onAck() + } + } + } + } + this.socket.on('serverEvent', this.serverEventHandler) + + // Start watching the temp dir + this.watcher = chokidar.watch(this.tmpDir, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, + ignored: [ + /(^|[/\\])\../, // dotfiles + /\.(aux|log|pdf|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb)$/ // LaTeX output files + ] + }) + + this.watcher.on('change', (absPath: string) => { + const relPath = absPath.replace(this.tmpDir + '/', '') + this.onFileChanged(relPath) + }) + + this.watcher.on('add', (absPath: string) => { + const relPath = absPath.replace(this.tmpDir + '/', '') + // Only process if it's a known doc + if (this.pathDocMap[relPath]) { + this.onFileChanged(relPath) + } + }) + + console.log(`[FileSyncBridge] started, watching ${this.tmpDir}, ${docIds.length} docs synced`) + } + + async stop(): Promise<void> { + this.stopped = true + + // Clear all debounce timers + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer) + } + this.debounceTimers.clear() + + // Remove server event handler + if (this.serverEventHandler) { + this.socket.removeListener('serverEvent', this.serverEventHandler) + this.serverEventHandler = null + } + + // Close watcher + if (this.watcher) { + await this.watcher.close() + this.watcher = null + } + + this.otClients.clear() + this.lastKnownContent.clear() + this.writesInProgress.clear() + this.editorDocs.clear() + + console.log('[FileSyncBridge] stopped') + } + + // ── Disk change handler ────────────────────────────────────── + + private onFileChanged(relPath: string): void { + if (this.stopped) return + + // Layer 1: Skip if bridge is currently writing this file + if (this.writesInProgress.has(relPath)) return + + // Layer 3: Debounce 300ms per file + const existing = this.debounceTimers.get(relPath) + if (existing) clearTimeout(existing) + + this.debounceTimers.set(relPath, setTimeout(() => { + this.debounceTimers.delete(relPath) + this.processChange(relPath) + }, 300)) + } + + private async processChange(relPath: string): Promise<void> { + if (this.stopped) return + + const docId = this.pathDocMap[relPath] + if (!docId) return + + let newContent: string + try { + newContent = await readFile(join(this.tmpDir, relPath), 'utf-8') + } catch { + return // file deleted or unreadable + } + + const lastKnown = this.lastKnownContent.get(relPath) + + // Layer 2: Content equality check + if (newContent === lastKnown) return + + console.log(`[FileSyncBridge] disk change detected: ${relPath} (${(newContent.length)} chars)`) + + if (this.editorDocs.has(docId)) { + // Doc is open in editor → send to renderer via IPC + this.lastKnownContent.set(relPath, newContent) + this.mainWindow.webContents.send('sync:externalEdit', { docId, content: newContent }) + } else { + // Doc NOT open in editor → bridge handles OT directly + const oldContent = lastKnown ?? '' + this.lastKnownContent.set(relPath, newContent) + + const diffs = dmp.diff_main(oldContent, newContent) + dmp.diff_cleanupEfficiency(diffs) + const ops = diffsToOtOps(diffs) + + if (ops.length > 0) { + const otClient = this.otClients.get(docId) + if (otClient) { + otClient.onLocalOps(ops) + } + } + } + } + + // ── Send OT ops to Overleaf (for non-editor docs) ─────────── + + private sendOps(docId: string, ops: OtOp[], version: number): void { + const relPath = this.docPathMap[docId] + const content = relPath ? this.lastKnownContent.get(relPath) ?? '' : '' + const hash = createHash('sha1').update(content).digest('hex') + this.socket.applyOtUpdate(docId, ops, version, hash) + } + + // ── Apply remote ops (for non-editor docs) ────────────────── + + private onRemoteApply(docId: string, ops: OtOp[]): void { + const relPath = this.docPathMap[docId] + if (!relPath) return + + const currentContent = this.lastKnownContent.get(relPath) ?? '' + const newContent = applyOpsToText(currentContent, ops) + this.lastKnownContent.set(relPath, newContent) + this.writeToDisk(relPath, newContent) + } + + // ── Called by main process when editor/remote changes content ─ + + /** Called when renderer notifies bridge that editor content changed */ + onEditorContentChanged(docId: string, content: string): void { + const relPath = this.docPathMap[docId] + if (!relPath) return + + // Update last known content + this.lastKnownContent.set(relPath, content) + + // Write to disk so external tools can see the change + this.writeToDisk(relPath, content) + } + + // ── Editor doc tracking ────────────────────────────────────── + + /** Renderer opened this doc in the editor — bridge stops owning OT */ + addEditorDoc(docId: string): void { + this.editorDocs.add(docId) + // Bridge's OtClient for this doc is no longer used (renderer has its own) + // But we keep the doc joined in the socket + } + + /** Renderer closed this doc from the editor — bridge takes over OT */ + removeEditorDoc(docId: string): void { + this.editorDocs.delete(docId) + + // Re-join the doc to get fresh version, since renderer's OtClient was tracking it + 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) + + // Create fresh OtClient with current version + const otClient = new OtClient( + result.version, + (ops, version) => this.sendOps(docId, ops, version), + (ops) => this.onRemoteApply(docId, ops) + ) + this.otClients.set(docId, otClient) + + // Write latest content to disk + this.writeToDisk(relPath, content) + }).catch((e) => { + console.log(`[FileSyncBridge] failed to re-join doc ${relPath}:`, e) + }) + } + + // ── Helpers ────────────────────────────────────────────────── + + private async writeToDisk(relPath: string, content: string): Promise<void> { + const fullPath = join(this.tmpDir, relPath) + const dir = fullPath.substring(0, fullPath.lastIndexOf('/')) + + // Set write guard + this.writesInProgress.add(relPath) + + try { + await mkdir(dir, { recursive: true }) + await writeFile(fullPath, content, 'utf-8') + } catch (e) { + console.log(`[FileSyncBridge] write error for ${relPath}:`, e) + } + + // Clear write guard after 150ms (chokidar needs time to fire & be ignored) + setTimeout(() => { + this.writesInProgress.delete(relPath) + }, 150) + } + + /** Get the temp dir path */ + get dir(): string { + return this.tmpDir + } + + /** Get content for a doc (used by compilation manager) */ + getDocContent(relPath: string): string | undefined { + return this.lastKnownContent.get(relPath) + } + + /** Check if a doc's content is known */ + hasDoc(relPath: string): boolean { + return this.lastKnownContent.has(relPath) + } +} + +// ── Utility functions ──────────────────────────────────────── + +/** Convert diff-match-patch diffs to OT ops */ +function diffsToOtOps(diffs: [number, string][]): OtOp[] { + const ops: OtOp[] = [] + let pos = 0 + + for (const [type, text] of diffs) { + switch (type) { + case 0: // DIFF_EQUAL + pos += text.length + break + case 1: // DIFF_INSERT + ops.push({ i: text, p: pos }) + pos += text.length + break + case -1: // DIFF_DELETE + ops.push({ d: text, p: pos }) + // Don't advance pos — deletion doesn't move cursor forward + break + } + } + + return ops +} + +/** Apply OT ops to a text string */ +function applyOpsToText(text: string, ops: OtOp[]): string { + // Sort ops by position descending so we can apply without position shifting + const sortedOps = [...ops].sort((a, b) => b.p - a.p) + + for (const op of sortedOps) { + if (isInsert(op)) { + text = text.slice(0, op.p) + op.i + text.slice(op.p) + } else if (isDelete(op)) { + text = text.slice(0, op.p) + text.slice(op.p + op.d.length) + } + // Comment ops don't modify text + } + + return text +} |
