From b116335f9dbde4f483c0b2b8e7bfca5d321c5dfc Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Thu, 12 Mar 2026 17:52:53 -0500 Subject: Add bidirectional file sync, OT system, comments, and real-time collaboration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron.vite.config.ts | 2 +- package-lock.json | 101 +- package.json | 7 +- src/main/compilationManager.ts | 162 +++ src/main/fileSyncBridge.ts | 364 +++++++ src/main/index.ts | 1219 +++++++++++++++------- src/main/otClient.ts | 131 +++ src/main/otTransform.ts | 117 +++ src/main/otTypes.ts | 31 + src/main/overleafProtocol.ts | 95 ++ src/main/overleafSocket.ts | 401 +++++++ src/preload/index.ts | 146 ++- src/renderer/src/App.css | 681 ++++++++++++ src/renderer/src/App.tsx | 368 ++++--- src/renderer/src/components/Editor.tsx | 264 ++++- src/renderer/src/components/FileTree.tsx | 371 +++++-- src/renderer/src/components/OverleafConnect.tsx | 171 --- src/renderer/src/components/PdfViewer.tsx | 34 +- src/renderer/src/components/ProjectList.tsx | 284 +++++ src/renderer/src/components/ReviewPanel.tsx | 309 ++++++ src/renderer/src/components/StatusBar.tsx | 20 +- src/renderer/src/components/Terminal.tsx | 7 +- src/renderer/src/components/Toolbar.tsx | 64 +- src/renderer/src/extensions/addCommentTooltip.ts | 97 ++ src/renderer/src/extensions/commentHighlights.ts | 227 ++++ src/renderer/src/extensions/otSyncExtension.ts | 39 + src/renderer/src/ot/cmAdapter.ts | 70 ++ src/renderer/src/ot/otClient.ts | 135 +++ src/renderer/src/ot/overleafSync.ts | 147 +++ src/renderer/src/ot/transform.ts | 174 +++ src/renderer/src/ot/types.ts | 53 + src/renderer/src/stores/appStore.ts | 136 ++- 32 files changed, 5367 insertions(+), 1060 deletions(-) create mode 100644 src/main/compilationManager.ts create mode 100644 src/main/fileSyncBridge.ts create mode 100644 src/main/otClient.ts create mode 100644 src/main/otTransform.ts create mode 100644 src/main/otTypes.ts create mode 100644 src/main/overleafProtocol.ts create mode 100644 src/main/overleafSocket.ts delete mode 100644 src/renderer/src/components/OverleafConnect.tsx create mode 100644 src/renderer/src/components/ProjectList.tsx create mode 100644 src/renderer/src/components/ReviewPanel.tsx create mode 100644 src/renderer/src/extensions/addCommentTooltip.ts create mode 100644 src/renderer/src/extensions/commentHighlights.ts create mode 100644 src/renderer/src/extensions/otSyncExtension.ts create mode 100644 src/renderer/src/ot/cmAdapter.ts create mode 100644 src/renderer/src/ot/otClient.ts create mode 100644 src/renderer/src/ot/overleafSync.ts create mode 100644 src/renderer/src/ot/transform.ts create mode 100644 src/renderer/src/ot/types.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d5d22d1..53f8477 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], build: { rollupOptions: { - external: ['node-pty'] + external: ['node-pty', 'ws', 'chokidar', 'diff-match-patch'] } } }, diff --git a/package-lock.json b/package-lock.json index a39a87a..a2e21b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,18 +18,21 @@ "@codemirror/view": "^6.34.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", + "diff-match-patch": "^1.0.5", "node-pty": "^1.0.0", "pdfjs-dist": "^4.9.155", "react": "^18.3.1", "react-dom": "^18.3.1", "react-resizable-panels": "^2.1.0", - "simple-git": "^3.27.0", + "ws": "^8.19.0", "zustand": "^5.0.0" }, "devDependencies": { + "@types/diff-match-patch": "^1.0.36", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.0", "electron": "^33.0.0", "electron-builder": "^25.1.0", @@ -1352,21 +1355,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@kwsites/file-exists": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1" - } - }, - "node_modules/@kwsites/promise-deferred": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "license": "MIT" - }, "node_modules/@lezer/common": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", @@ -2239,6 +2227,13 @@ "@types/ms": "*" } }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2348,6 +2343,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -3210,15 +3215,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -3605,6 +3610,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3743,6 +3749,12 @@ "license": "MIT", "optional": true }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -5745,6 +5757,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6392,12 +6405,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -6686,21 +6699,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-git": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", - "integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==", - "license": "MIT", - "dependencies": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/steveukx/git-js?sponsor=1" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -7380,6 +7378,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index a12e423..ffcf155 100644 --- a/package.json +++ b/package.json @@ -21,18 +21,21 @@ "@codemirror/view": "^6.34.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", + "diff-match-patch": "^1.0.5", "node-pty": "^1.0.0", "pdfjs-dist": "^4.9.155", "react": "^18.3.1", "react-dom": "^18.3.1", "react-resizable-panels": "^2.1.0", - "simple-git": "^3.27.0", + "ws": "^8.19.0", "zustand": "^5.0.0" }, "devDependencies": { + "@types/diff-match-patch": "^1.0.36", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.0", "electron": "^33.0.0", "electron-builder": "^25.1.0", diff --git a/src/main/compilationManager.ts b/src/main/compilationManager.ts new file mode 100644 index 0000000..3529345 --- /dev/null +++ b/src/main/compilationManager.ts @@ -0,0 +1,162 @@ +// Manages temp directory for Overleaf socket-mode compilation +import { join, basename } from 'path' +import { writeFile, mkdir, rm } from 'fs/promises' +import { existsSync } from 'fs' +import { spawn } from 'child_process' +import { net } from 'electron' + +export class CompilationManager { + private tmpDir: string + private projectId: string + private cookie: string + private docContents = new Map() // docPath → content + private fileRefCache = new Map() // fileRefPath → downloaded + + constructor(projectId: string, cookie: string) { + this.projectId = projectId + this.cookie = cookie + this.tmpDir = join(require('os').tmpdir(), `claudetex-${projectId}`) + } + + get dir(): string { + return this.tmpDir + } + + /** Check if a doc is already stored */ + hasDoc(relativePath: string): boolean { + return this.docContents.has(relativePath) + } + + /** Store doc content (called when docs are joined/updated) */ + setDocContent(relativePath: string, content: string) { + // Strip C1 control characters (U+0080-U+009F) — Overleaf embeds these as + // range markers for tracked changes / comments. They break pdflatex. + this.docContents.set(relativePath, content.replace(/[\u0080-\u009F]/g, '')) + } + + /** Write all doc contents to disk */ + async syncDocs(): Promise { + await mkdir(this.tmpDir, { recursive: true }) + for (const [relPath, content] of this.docContents) { + const fullPath = join(this.tmpDir, relPath) + const dir = fullPath.substring(0, fullPath.lastIndexOf('/')) + await mkdir(dir, { recursive: true }) + await writeFile(fullPath, content, 'utf-8') + } + } + + /** Download a binary file (image, .bst, etc.) from Overleaf */ + async downloadFile(fileRefId: string, relativePath: string): Promise { + if (this.fileRefCache.has(relativePath)) return + + const fullPath = join(this.tmpDir, relativePath) + const dir = fullPath.substring(0, fullPath.lastIndexOf('/')) + await mkdir(dir, { recursive: true }) + + return new Promise((resolve, reject) => { + const url = `https://www.overleaf.com/project/${this.projectId}/file/${fileRefId}` + const req = net.request(url) + req.setHeader('Cookie', this.cookie) + req.setHeader('User-Agent', 'Mozilla/5.0') + + const chunks: Buffer[] = [] + req.on('response', (res) => { + res.on('data', (chunk) => chunks.push(chunk as Buffer)) + res.on('end', async () => { + try { + const { writeFile: wf } = await import('fs/promises') + await wf(fullPath, Buffer.concat(chunks)) + this.fileRefCache.set(relativePath, true) + resolve() + } catch (e) { + reject(e) + } + }) + }) + req.on('error', reject) + req.end() + }) + } + + /** Download all binary files in the project */ + async syncBinaries(fileRefs: Array<{ id: string; path: string }>): Promise { + for (const ref of fileRefs) { + try { + await this.downloadFile(ref.id, ref.path) + } catch (e) { + console.log(`[CompilationManager] failed to download ${ref.path}:`, e) + } + } + } + + /** Run latexmk compilation */ + async compile( + mainTexRelPath: string, + onLog: (data: string) => void + ): Promise<{ success: boolean; log: string; pdfPath: string }> { + await this.syncDocs() + + const texPaths = [ + '/Library/TeX/texbin', + '/usr/local/texlive/2024/bin/universal-darwin', + '/usr/texbin', + '/opt/homebrew/bin' + ] + const envPath = texPaths.join(':') + ':' + (process.env.PATH || '') + + // Use // suffix for recursive search of ALL subdirectories in the project tree. + // This ensures .sty, .bst, .cls, images, etc. are always found regardless of nesting. + const texInputs = `${this.tmpDir}//:` + const texBase = basename(mainTexRelPath, '.tex') + const pdfPath = join(this.tmpDir, texBase + '.pdf') + + const args = [ + '-pdf', '-f', '-g', '-bibtex', '-synctex=1', + '-interaction=nonstopmode', '-file-line-error', + '-outdir=' + this.tmpDir, + mainTexRelPath + ] + console.log('[compile] cwd:', this.tmpDir) + console.log('[compile] args:', args.join(' ')) + console.log('[compile] TEXINPUTS:', texInputs) + console.log('[compile] pdfPath:', pdfPath) + console.log('[compile] docs synced:', this.docContents.size, 'files:', [...this.docContents.keys()].slice(0, 5)) + + return new Promise((resolve) => { + let log = '' + const proc = spawn('latexmk', args, { + cwd: this.tmpDir, + env: { ...process.env, PATH: envPath, TEXINPUTS: texInputs, BIBINPUTS: texInputs, BSTINPUTS: texInputs } + }) + + proc.stdout.on('data', (data) => { + const s = data.toString() + log += s + onLog(s) + }) + + proc.stderr.on('data', (data) => { + const s = data.toString() + log += s + onLog(s) + }) + + proc.on('close', (code) => { + resolve({ success: code === 0, log, pdfPath }) + }) + + proc.on('error', (err) => { + resolve({ success: false, log: log + '\n' + err.message, pdfPath }) + }) + }) + } + + /** Clean up temp directory */ + async cleanup(): Promise { + try { + if (existsSync(this.tmpDir)) { + await rm(this.tmpDir, { recursive: true }) + } + } catch { /* ignore */ } + } +} 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() // relPath → content + private writesInProgress = new Set() // relPaths being written by bridge + private debounceTimers = new Map>() + private otClients = new Map() // docId → OtClient (non-editor docs) + private editorDocs = new Set() // docIds owned by renderer + private watcher: chokidar.FSWatcher | null = null + + private socket: OverleafSocket + private tmpDir: string + private docPathMap: Record // docId → relPath + private pathDocMap: Record // relPath → docId + private mainWindow: BrowserWindow + + private serverEventHandler: ((name: string, args: unknown[]) => void) | null = null + private stopped = false + + constructor( + socket: OverleafSocket, + tmpDir: string, + docPathMap: Record, + pathDocMap: Record, + mainWindow: BrowserWindow + ) { + this.socket = socket + this.tmpDir = tmpDir + this.docPathMap = docPathMap + this.pathDocMap = pathDocMap + this.mainWindow = mainWindow + } + + async start(): Promise { + // 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 { + 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 { + 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 { + 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 +} diff --git a/src/main/index.ts b/src/main/index.ts index 0adbe79..21b6e43 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,14 +1,17 @@ import { app, BrowserWindow, ipcMain, dialog, shell, net } from 'electron' -import { join, basename, extname, dirname } from 'path' -import { readdir, readFile, writeFile, stat, mkdir, rename, unlink, rm } from 'fs/promises' -import { spawn, type ChildProcess } from 'child_process' -import { watch } from 'chokidar' +import { join, basename } from 'path' +import { readFile, writeFile } from 'fs/promises' +import { spawn } from 'child_process' import * as pty from 'node-pty' +import { OverleafSocket, type RootFolder, type SubFolder, type JoinDocResult } from './overleafSocket' +import { CompilationManager } from './compilationManager' +import { FileSyncBridge } from './fileSyncBridge' let mainWindow: BrowserWindow | null = null let ptyInstance: pty.IPty | null = null -let fileWatcher: ReturnType | null = null -let compileProcess: ChildProcess | null = null +let overleafSock: OverleafSocket | null = null +let compilationManager: CompilationManager | null = null +let fileSyncBridge: FileSyncBridge | null = null function createWindow(): void { mainWindow = new BrowserWindow({ @@ -32,158 +35,15 @@ function createWindow(): void { } } -// ── File System IPC ────────────────────────────────────────────── - -interface FileNode { - name: string - path: string - isDir: boolean - children?: FileNode[] -} - -async function readDirRecursive(dirPath: string, depth = 0): Promise { - if (depth > 5) return [] - const entries = await readdir(dirPath, { withFileTypes: true }) - const nodes: FileNode[] = [] - - for (const entry of entries) { - if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'out') continue - - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - const children = await readDirRecursive(fullPath, depth + 1) - nodes.push({ name: entry.name, path: fullPath, isDir: true, children }) - } else { - const ext = extname(entry.name).toLowerCase() - if (['.tex', '.bib', '.cls', '.sty', '.bst', '.txt', '.md', '.log', '.aux', '.pdf', '.png', '.jpg', '.jpeg', '.svg'].includes(ext)) { - nodes.push({ name: entry.name, path: fullPath, isDir: false }) - } - } - } - - return nodes.sort((a, b) => { - if (a.isDir && !b.isDir) return -1 - if (!a.isDir && b.isDir) return 1 - return a.name.localeCompare(b.name) - }) -} - -ipcMain.handle('dialog:openProject', async () => { - const result = await dialog.showOpenDialog(mainWindow!, { - properties: ['openDirectory'], - title: 'Open LaTeX Project' - }) - if (result.canceled) return null - return result.filePaths[0] -}) - -ipcMain.handle('dialog:selectSaveDir', async () => { - const result = await dialog.showOpenDialog(mainWindow!, { - properties: ['openDirectory', 'createDirectory'], - title: 'Choose where to clone the project' - }) - if (result.canceled) return null - return result.filePaths[0] -}) - -ipcMain.handle('fs:readDir', async (_e, dirPath: string) => { - return readDirRecursive(dirPath) -}) - ipcMain.handle('fs:readFile', async (_e, filePath: string) => { return readFile(filePath, 'utf-8') }) -// Find the main .tex file (contains \documentclass) in a project -ipcMain.handle('fs:findMainTex', async (_e, dirPath: string) => { - async function search(dir: string, depth: number): Promise { - if (depth > 3) return null - const entries = await readdir(dir, { withFileTypes: true }) - const texFiles: string[] = [] - const dirs: string[] = [] - for (const entry of entries) { - if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'out') continue - const full = join(dir, entry.name) - if (entry.isDirectory()) dirs.push(full) - else if (entry.name.endsWith('.tex')) texFiles.push(full) - } - for (const f of texFiles) { - try { - const content = await readFile(f, 'utf-8') - if (/\\documentclass/.test(content)) return f - } catch { /* skip */ } - } - for (const d of dirs) { - const found = await search(d, depth + 1) - if (found) return found - } - return null - } - return search(dirPath, 0) -}) - ipcMain.handle('fs:readBinary', async (_e, filePath: string) => { const buffer = await readFile(filePath) return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) }) -ipcMain.handle('fs:writeFile', async (_e, filePath: string, content: string) => { - await writeFile(filePath, content, 'utf-8') -}) - -ipcMain.handle('fs:createFile', async (_e, dirPath: string, fileName: string) => { - const fullPath = join(dirPath, fileName) - await writeFile(fullPath, '', 'utf-8') - return fullPath -}) - -ipcMain.handle('fs:createDir', async (_e, dirPath: string, dirName: string) => { - const fullPath = join(dirPath, dirName) - await mkdir(fullPath, { recursive: true }) - return fullPath -}) - -ipcMain.handle('fs:rename', async (_e, oldPath: string, newPath: string) => { - await rename(oldPath, newPath) -}) - -ipcMain.handle('fs:delete', async (_e, filePath: string) => { - const s = await stat(filePath) - if (s.isDirectory()) { - await rm(filePath, { recursive: true }) - } else { - await unlink(filePath) - } -}) - -ipcMain.handle('fs:stat', async (_e, filePath: string) => { - const s = await stat(filePath) - return { isDir: s.isDirectory(), size: s.size, mtime: s.mtimeMs } -}) - -// ── File Watcher ───────────────────────────────────────────────── - -ipcMain.handle('watcher:start', async (_e, dirPath: string) => { - if (fileWatcher) { - await fileWatcher.close() - } - fileWatcher = watch(dirPath, { - ignored: /(^|[/\\])(\.|node_modules|out|\.aux|\.log|\.fls|\.fdb_latexmk|\.synctex)/, - persistent: true, - depth: 5 - }) - fileWatcher.on('all', (event, path) => { - mainWindow?.webContents.send('watcher:change', { event, path }) - }) -}) - -ipcMain.handle('watcher:stop', async () => { - if (fileWatcher) { - await fileWatcher.close() - fileWatcher = null - } -}) - // ── LaTeX Compilation ──────────────────────────────────────────── // Ensure TeX binaries are in PATH (Electron launched from Finder may miss them) @@ -195,113 +55,6 @@ for (const p of texPaths) { } } -// Parse missing packages from compile log -function parseMissingPackages(log: string): string[] { - const missing = new Set() - // Match "File `xxx.sty' not found" - const styRegex = /File `([^']+\.sty)' not found/g - let m: RegExpExecArray | null - while ((m = styRegex.exec(log)) !== null) { - missing.add(m[1].replace(/\.sty$/, '')) - } - // Match "Metric (TFM) file not found" for fonts - const tfmRegex = /Font [^=]+=(\w+) .* not loadable: Metric/g - while ((m = tfmRegex.exec(log)) !== null) { - missing.add(m[1]) - } - return [...missing] -} - -// Find which tlmgr packages provide the missing files -async function findTlmgrPackages(names: string[]): Promise { - const packages = new Set() - for (const name of names) { - const result = await new Promise((resolve) => { - let out = '' - const proc = spawn('tlmgr', ['search', '--file', `${name}.sty`], { env: process.env }) - proc.stdout?.on('data', (d) => { out += d.toString() }) - proc.stderr?.on('data', (d) => { out += d.toString() }) - proc.on('close', () => resolve(out)) - proc.on('error', () => resolve('')) - }) - // tlmgr search output: "package_name:\n texmf-dist/..." - const pkgMatch = result.match(/^(\S+):$/m) - if (pkgMatch) { - packages.add(pkgMatch[1]) - } else { - // Fallback: use the name itself as package name - packages.add(name) - } - } - return [...packages] -} - -ipcMain.handle('latex:compile', async (_e, filePath: string) => { - if (compileProcess) { - compileProcess.kill() - } - - const dir = dirname(filePath) - const file = basename(filePath) - - return new Promise<{ success: boolean; log: string; missingPackages?: string[] }>((resolve) => { - let log = '' - compileProcess = spawn('latexmk', ['-pdf', '-f', '-g', '-bibtex', '-synctex=1', '-interaction=nonstopmode', '-file-line-error', file], { - cwd: dir, - env: process.env - }) - - compileProcess.stdout?.on('data', (data) => { - log += data.toString() - mainWindow?.webContents.send('latex:log', data.toString()) - }) - compileProcess.stderr?.on('data', (data) => { - log += data.toString() - mainWindow?.webContents.send('latex:log', data.toString()) - }) - compileProcess.on('close', async (code) => { - compileProcess = null - if (code !== 0) { - const missing = parseMissingPackages(log) - if (missing.length > 0) { - const packages = await findTlmgrPackages(missing) - resolve({ success: false, log, missingPackages: packages }) - return - } - } - resolve({ success: code === 0, log }) - }) - compileProcess.on('error', (err) => { - compileProcess = null - resolve({ success: false, log: err.message }) - }) - }) -}) - -// Install TeX packages via tlmgr (runs in PTY so sudo can prompt for password) -ipcMain.handle('latex:installPackages', async (_e, packages: string[]) => { - if (!packages.length) return { success: false, message: 'No packages specified' } - - // Try without sudo first - const tryDirect = await new Promise<{ success: boolean; message: string }>((resolve) => { - let out = '' - const proc = spawn('tlmgr', ['install', ...packages], { env: process.env }) - proc.stdout?.on('data', (d) => { out += d.toString() }) - proc.stderr?.on('data', (d) => { out += d.toString() }) - proc.on('close', (code) => resolve({ success: code === 0, message: out })) - proc.on('error', (err) => resolve({ success: false, message: err.message })) - }) - - if (tryDirect.success) return tryDirect - - // Need sudo — run in PTY terminal so user can enter password - return { success: false, message: 'need_sudo', packages } -}) - -ipcMain.handle('latex:getPdfPath', async (_e, texPath: string) => { - return texPath.replace(/\.tex$/, '.pdf') -}) - // SyncTeX: PDF position → source file:line (inverse search) ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number, x: number, y: number) => { return new Promise<{ file: string; line: number } | null>((resolve) => { @@ -326,33 +79,6 @@ ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number, }) }) -// SyncTeX: source file:line → PDF page + position (forward search) -ipcMain.handle('synctex:viewFromSource', async (_e, texPath: string, line: number, pdfPath: string) => { - return new Promise<{ page: number; x: number; y: number } | null>((resolve) => { - const proc = spawn('synctex', ['view', '-i', `${line}:0:${texPath}`, '-o', pdfPath], { - env: process.env - }) - let out = '' - proc.stdout?.on('data', (d) => { out += d.toString() }) - proc.stderr?.on('data', (d) => { out += d.toString() }) - proc.on('close', () => { - const pageMatch = out.match(/Page:(\d+)/) - const xMatch = out.match(/x:([0-9.]+)/) - const yMatch = out.match(/y:([0-9.]+)/) - if (pageMatch) { - resolve({ - page: parseInt(pageMatch[1]), - x: xMatch ? parseFloat(xMatch[1]) : 0, - y: yMatch ? parseFloat(yMatch[1]) : 0 - }) - } else { - resolve(null) - } - }) - proc.on('error', () => resolve(null)) - }) -}) - // ── Terminal / PTY ─────────────────────────────────────────────── ipcMain.handle('pty:spawn', async (_e, cwd: string) => { @@ -393,161 +119,844 @@ ipcMain.handle('pty:kill', async () => { ptyInstance = null }) -// ── Overleaf / Git Sync ────────────────────────────────────────── +// ── Overleaf Web Session (for comments) ───────────────────────── + +let overleafSessionCookie = '' +let overleafCsrfToken = '' + +// Persist cookie to disk +const cookiePath = join(app.getPath('userData'), 'overleaf-session.json') + +async function saveOverleafSession(): Promise { + try { + await writeFile(cookiePath, JSON.stringify({ cookie: overleafSessionCookie, csrf: overleafCsrfToken })) + } catch { /* ignore */ } +} + +let sessionLoadPromise: Promise | null = null -// Helper: run git with explicit credentials via a temp credential helper script -function gitWithCreds(args: string[], email: string, password: string, cwd?: string): Promise<{ success: boolean; message: string }> { +async function loadOverleafSession(): Promise { + try { + const raw = await readFile(cookiePath, 'utf-8') + const data = JSON.parse(raw) + if (data.cookie) { + overleafSessionCookie = data.cookie + overleafCsrfToken = data.csrf || '' + console.log('[overleaf] loaded saved session, verifying...') + // Verify it's still valid + const result = await overleafFetch('/user/projects') + if (!result.ok) { + console.log('[overleaf] saved session expired (status:', result.status, ')') + overleafSessionCookie = '' + overleafCsrfToken = '' + } else { + console.log('[overleaf] saved session is valid') + } + } + } catch { /* no saved session */ } +} + +// Helper: make authenticated request to Overleaf web API +async function overleafFetch(path: string, options: { method?: string; body?: string; raw?: boolean; cookie?: string } = {}): Promise<{ ok: boolean; status: number; data: unknown; setCookies: string[] }> { return new Promise((resolve) => { - // Use inline credential helper that echoes stored creds - const helper = `!f() { echo "username=${email}"; echo "password=${password}"; }; f` - const fullArgs = ['-c', `credential.helper=${helper}`, ...args] - console.log('[git]', args[0], args.slice(1).join(' ').replace(password, '***')) - const proc = spawn('git', fullArgs, { - cwd, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } - }) - let output = '' - proc.stdout?.on('data', (d) => { output += d.toString() }) - proc.stderr?.on('data', (d) => { output += d.toString() }) - proc.on('close', (code) => { - console.log('[git] exit code:', code, 'output:', output.slice(0, 300)) - resolve({ success: code === 0, message: output }) + const url = `https://www.overleaf.com${path}` + const request = net.request({ url, method: options.method || 'GET' }) + request.setHeader('Cookie', options.cookie || overleafSessionCookie) + request.setHeader('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.191 Safari/537.36') + if (!options.raw) { + request.setHeader('Accept', 'application/json') + } + if (options.body) { + request.setHeader('Content-Type', options.raw ? 'text/plain; charset=UTF-8' : 'application/json') + } + if (overleafCsrfToken && options.method && options.method !== 'GET') { + request.setHeader('x-csrf-token', overleafCsrfToken) + } + + let body = '' + request.on('response', (response) => { + const sc = response.headers['set-cookie'] + const setCookies = Array.isArray(sc) ? sc : sc ? [sc] : [] + response.on('data', (chunk) => { body += chunk.toString() }) + response.on('end', () => { + let data: unknown = body + if (!options.raw) { + try { data = JSON.parse(body) } catch { /* not json */ } + } + resolve({ ok: response.statusCode >= 200 && response.statusCode < 300, status: response.statusCode, data, setCookies }) + }) }) - proc.on('error', (err) => { - console.log('[git] error:', err.message) - resolve({ success: false, message: err.message }) + request.on('error', (err) => { + resolve({ ok: false, status: 0, data: err.message, setCookies: [] }) }) + + if (options.body) request.write(options.body) + request.end() }) } -// Helper: run git with osxkeychain (for after credentials are stored) -function gitSpawn(args: string[], cwd?: string): Promise<{ success: boolean; message: string }> { - return new Promise((resolve) => { - const fullArgs = ['-c', 'credential.helper=osxkeychain', ...args] - const proc = spawn('git', fullArgs, { - cwd, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } +// Login via webview — opens Overleaf login page, captures session cookie +ipcMain.handle('overleaf:webLogin', async () => { + return new Promise<{ success: boolean }>((resolve) => { + const loginWindow = new BrowserWindow({ + width: 900, + height: 750, + parent: mainWindow!, + modal: true, + webPreferences: { nodeIntegration: false, contextIsolation: true } }) - let output = '' - proc.stdout?.on('data', (d) => { output += d.toString() }) - proc.stderr?.on('data', (d) => { output += d.toString() }) - proc.on('close', (code) => { - resolve({ success: code === 0, message: output }) - }) - proc.on('error', (err) => { - resolve({ success: false, message: err.message }) + + loginWindow.loadURL('https://www.overleaf.com/login') + + // Inject a floating back button when navigated away from overleaf.com + const injectBackButton = () => { + loginWindow.webContents.executeJavaScript(` + if (!document.getElementById('claudetex-back-btn')) { + const btn = document.createElement('div'); + btn.id = 'claudetex-back-btn'; + btn.innerHTML = '← Back'; + btn.style.cssText = 'position:fixed;top:8px;left:8px;z-index:999999;padding:6px 14px;' + + 'background:#333;color:#fff;border-radius:6px;cursor:pointer;font:13px -apple-system,sans-serif;' + + 'box-shadow:0 2px 8px rgba(0,0,0,.3);user-select:none;-webkit-app-region:no-drag;'; + btn.addEventListener('click', () => history.back()); + btn.addEventListener('mouseenter', () => btn.style.background = '#555'); + btn.addEventListener('mouseleave', () => btn.style.background = '#333'); + document.body.appendChild(btn); + } + `).catch(() => {}) + } + + loginWindow.webContents.on('did-finish-load', injectBackButton) + loginWindow.webContents.on('did-navigate-in-page', injectBackButton) + + // Verify cookie by calling Overleaf API — only succeed if we get 200 + const verifyAndCapture = async (): Promise => { + const cookies = await loginWindow.webContents.session.cookies.get({ domain: '.overleaf.com' }) + if (!cookies.find((c) => c.name === 'overleaf_session2')) return false + + const testCookie = cookies.map((c) => `${c.name}=${c.value}`).join('; ') + // Test if this cookie is actually authenticated + const ok = await new Promise((res) => { + const req = net.request({ url: 'https://www.overleaf.com/user/projects', method: 'GET' }) + req.setHeader('Cookie', testCookie) + req.setHeader('Accept', 'application/json') + req.on('response', (resp) => { + resp.on('data', () => {}) + resp.on('end', () => res(resp.statusCode === 200)) + }) + req.on('error', () => res(false)) + req.end() + }) + + if (!ok) return false + + overleafSessionCookie = testCookie + // Get CSRF from meta tag if we're on an Overleaf page + try { + const csrf = await loginWindow.webContents.executeJavaScript( + `document.querySelector('meta[name="ol-csrfToken"]')?.content || ''` + ) + if (csrf) overleafCsrfToken = csrf + } catch { /* ignore */ } + + // If no CSRF from page, fetch from /project page + if (!overleafCsrfToken) { + await new Promise((res) => { + const req = net.request({ url: 'https://www.overleaf.com/project', method: 'GET' }) + req.setHeader('Cookie', overleafSessionCookie) + let body = '' + req.on('response', (resp) => { + resp.on('data', (chunk) => { body += chunk.toString() }) + resp.on('end', () => { + const m = body.match(/ol-csrfToken[^>]*content="([^"]+)"/) + if (m) overleafCsrfToken = m[1] + res() + }) + }) + req.on('error', () => res()) + req.end() + }) + } + + return true + } + + let resolved = false + const tryCapture = async () => { + if (resolved) return + const ok = await verifyAndCapture() + if (ok && !resolved) { + resolved = true + saveOverleafSession() + loginWindow.close() + resolve({ success: true }) + } + } + + loginWindow.webContents.on('did-navigate', () => { setTimeout(tryCapture, 2000) }) + loginWindow.webContents.on('did-navigate-in-page', () => { setTimeout(tryCapture, 2000) }) + + loginWindow.on('closed', () => { + if (!overleafSessionCookie) resolve({ success: false }) }) }) -} +}) -// Store credentials in macOS Keychain (no verification — that happens in overleaf:cloneWithAuth) -function storeCredentials(email: string, password: string): Promise { - return new Promise((resolve) => { - // Erase old first - const erase = spawn('git', ['credential-osxkeychain', 'erase']) - erase.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`) - erase.stdin?.end() - erase.on('close', () => { - const store = spawn('git', ['credential-osxkeychain', 'store']) - store.stdin?.write(`protocol=https\nhost=git.overleaf.com\nusername=${email}\npassword=${password}\n\n`) - store.stdin?.end() - store.on('close', (code) => resolve(code === 0)) - }) +// Check if web session is active — wait for startup load to finish +ipcMain.handle('overleaf:hasWebSession', async () => { + if (sessionLoadPromise) await sessionLoadPromise + return { loggedIn: !!overleafSessionCookie } +}) + +// Fetch all comment threads for a project +ipcMain.handle('overleaf:getThreads', async (_e, projectId: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + const result = await overleafFetch(`/project/${projectId}/threads`) + if (!result.ok) return { success: false, message: `HTTP ${result.status}` } + return { success: true, threads: result.data } +}) + +// Reply to a thread +ipcMain.handle('overleaf:replyThread', async (_e, projectId: string, threadId: string, content: string) => { + if (!overleafSessionCookie) return { success: false } + const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages`, { + method: 'POST', + body: JSON.stringify({ content }) + }) + return { success: result.ok, data: result.data } +}) + +// Resolve a thread +ipcMain.handle('overleaf:resolveThread', async (_e, projectId: string, threadId: string) => { + if (!overleafSessionCookie) return { success: false } + const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/resolve`, { + method: 'POST', + body: '{}' }) + return { success: result.ok } +}) + +// Reopen a thread +ipcMain.handle('overleaf:reopenThread', async (_e, projectId: string, threadId: string) => { + if (!overleafSessionCookie) return { success: false } + const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/reopen`, { + method: 'POST', + body: '{}' + }) + return { success: result.ok } +}) + +// Delete a comment message +ipcMain.handle('overleaf:deleteMessage', async (_e, projectId: string, threadId: string, messageId: string) => { + if (!overleafSessionCookie) return { success: false } + const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages/${messageId}`, { + method: 'DELETE' + }) + return { success: result.ok } +}) + +// Edit a comment message +ipcMain.handle('overleaf:editMessage', async (_e, projectId: string, threadId: string, messageId: string, content: string) => { + if (!overleafSessionCookie) return { success: false } + const result = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages/${messageId}/edit`, { + method: 'POST', + body: JSON.stringify({ content }) + }) + return { success: result.ok } +}) + +// Delete entire thread +ipcMain.handle('overleaf:deleteThread', async (_e, projectId: string, docId: string, threadId: string) => { + if (!overleafSessionCookie) return { success: false } + const result = await overleafFetch(`/project/${projectId}/doc/${docId}/thread/${threadId}`, { + method: 'DELETE' + }) + return { success: result.ok } +}) + +// Add a new comment: create thread via REST then submit op via Socket.IO +async function addComment( + projectId: string, + docId: string, + pos: number, + text: string, + content: string +): Promise<{ success: boolean; threadId?: string; message?: string }> { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + + // Generate a random threadId (24-char hex like Mongo ObjectId) + const threadId = Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('') + + // Step 1: Create the thread message via REST + const msgResult = await overleafFetch(`/project/${projectId}/thread/${threadId}/messages`, { + method: 'POST', + body: JSON.stringify({ content }) + }) + if (!msgResult.ok) return { success: false, message: `REST failed: ${msgResult.status}` } + + // Step 2: Submit the comment op via Socket.IO WebSocket + const hsRes = await overleafFetch(`/socket.io/1/?t=${Date.now()}&projectId=${projectId}`, { raw: true }) + if (!hsRes.ok) return { success: false, message: 'handshake failed' } + const sid = (hsRes.data as string).split(':')[0] + if (!sid) return { success: false, message: 'no sid' } + + const { session: electronSession } = await import('electron') + const ses = electronSession.fromPartition('overleaf-sio-add-' + Date.now()) + + ses.webRequest.onHeadersReceived((details, callback) => { + const headers = { ...details.responseHeaders } + delete headers['set-cookie'] + delete headers['Set-Cookie'] + callback({ responseHeaders: headers }) + }) + + const allCookieParts = overleafSessionCookie.split('; ') + for (const sc of hsRes.setCookies) { + allCookieParts.push(sc.split(';')[0]) + } + for (const pair of allCookieParts) { + const eqIdx = pair.indexOf('=') + if (eqIdx < 0) continue + try { + await ses.cookies.set({ + url: 'https://www.overleaf.com', + name: pair.substring(0, eqIdx), + value: pair.substring(eqIdx + 1), + domain: '.overleaf.com', + path: '/', + secure: true + }) + } catch { /* ignore */ } + } + + const win = new BrowserWindow({ + width: 800, height: 600, show: false, + webPreferences: { nodeIntegration: false, contextIsolation: false, session: ses } + }) + + try { + win.webContents.on('console-message', (_e, _level, msg) => { + console.log('[overleaf-add-comment]', msg) + }) + await win.loadURL('https://www.overleaf.com/login') + + const script = ` + new Promise(async (mainResolve) => { + try { + var ws = new WebSocket('wss://' + location.host + '/socket.io/1/websocket/${sid}'); + var ackId = 0, ackCbs = {}, evtCbs = {}; + + ws.onmessage = function(e) { + var d = e.data; + if (d === '2::') { ws.send('2::'); return; } + if (d === '1::') return; + var am = d.match(/^6:::(\\d+)\\+([\\s\\S]*)/); + if (am) { + var cb = ackCbs[parseInt(am[1])]; + if (cb) { delete ackCbs[parseInt(am[1])]; try { cb(JSON.parse(am[2])); } catch(e2) { cb(null); } } + return; + } + var em2 = d.match(/^5:::(\\{[\\s\\S]*\\})/); + if (em2) { + try { + var evt = JSON.parse(em2[1]); + var ecb = evtCbs[evt.name]; + if (ecb) { delete evtCbs[evt.name]; ecb(evt.args); } + } catch(e3) {} + } + }; + + function emitAck(name, args) { + return new Promise(function(res) { ackId++; ackCbs[ackId] = res; + ws.send('5:' + ackId + '+::' + JSON.stringify({ name: name, args: args })); }); + } + function waitEvent(name) { + return new Promise(function(res) { evtCbs[name] = res; }); + } + + ws.onerror = function() { mainResolve({ error: 'ws_error' }); }; + ws.onclose = function(ev) { console.log('ws closed: ' + ev.code); }; + + ws.onopen = async function() { + try { + var jpPromise = waitEvent('joinProjectResponse'); + ws.send('5:::' + JSON.stringify({ name: 'joinProject', args: [{ project_id: '${projectId}' }] })); + await jpPromise; + + // Join the doc to submit the op + await emitAck('joinDoc', ['${docId}']); + + // Submit the comment op + var commentOp = { c: ${JSON.stringify(text)}, p: ${pos}, t: '${threadId}' }; + console.log('submitting op: ' + JSON.stringify(commentOp)); + await emitAck('applyOtUpdate', ['${docId}', { doc: '${docId}', op: [commentOp], v: 0 }]); + + await emitAck('leaveDoc', ['${docId}']); + ws.close(); + mainResolve({ success: true }); + } catch (e) { ws.close(); mainResolve({ error: e.message }); } + }; + setTimeout(function() { ws.close(); mainResolve({ error: 'timeout' }); }, 30000); + } catch (e) { mainResolve({ error: e.message }); } + }); + ` + + const result = await win.webContents.executeJavaScript(script) + console.log('[overleaf] addComment result:', result) + + if (result?.error) return { success: false, message: result.error } + return { success: true, threadId } + } catch (e) { + console.log('[overleaf] addComment error:', e) + return { success: false, message: String(e) } + } finally { + win.close() + } +} + +ipcMain.handle('overleaf:addComment', async (_e, projectId: string, docId: string, pos: number, text: string, content: string) => { + return addComment(projectId, docId, pos, text, content) +}) + +// ── OT / Socket Mode IPC ───────────────────────────────────────── + +interface SocketFileNode { + name: string + path: string + isDir: boolean + children?: SocketFileNode[] + docId?: string + fileRefId?: string + folderId?: string } -// Verify credentials + project access using git ls-remote, then clone -// Overleaf git auth: username is always literal "git", password is the token -ipcMain.handle('overleaf:cloneWithAuth', async (_e, projectId: string, dest: string, token: string, remember: boolean) => { - const repoUrl = `https://git.overleaf.com/${projectId}` - console.log('[overleaf:cloneWithAuth] Verifying access to:', projectId) - - // Step 1: ls-remote to verify both auth and project access - // Username must be "git" (not email), password is the olp_ token - const verify = await gitWithCreds(['ls-remote', '--heads', repoUrl], 'git', token) - - if (!verify.success) { - const msg = verify.message - console.log('[overleaf:cloneWithAuth] ls-remote failed:', msg) - if (msg.includes('only supports Git authentication tokens') || msg.includes('token')) { - return { success: false, message: 'need_token', detail: 'Overleaf requires a Git Authentication Token (not your password).\n\n1. Go to Overleaf → Account Settings\n2. Find "Git Integration"\n3. Generate a token and paste it here.' } +function walkRootFolder(folders: RootFolder[]): { + files: SocketFileNode[] + docPathMap: Record + pathDocMap: Record + fileRefs: Array<{ id: string; path: string }> + rootFolderId: string +} { + const docPathMap: Record = {} + const pathDocMap: Record = {} + const fileRefs: Array<{ id: string; path: string }> = [] + + function walkFolder(f: SubFolder | RootFolder, prefix: string): SocketFileNode[] { + const nodes: SocketFileNode[] = [] + + for (const doc of f.docs || []) { + const relPath = prefix + doc.name + docPathMap[doc._id] = relPath + pathDocMap[relPath] = doc._id + nodes.push({ + name: doc.name, + path: relPath, + isDir: false, + docId: doc._id + }) } - if (msg.includes('Authentication failed') || msg.includes('401') || msg.includes('403') || msg.includes('could not read')) { - return { success: false, message: 'auth_failed', detail: 'Authentication failed. Make sure you are using a Git Authentication Token, not your Overleaf password.' } + + for (const ref of f.fileRefs || []) { + const relPath = prefix + ref.name + fileRefs.push({ id: ref._id, path: relPath }) + nodes.push({ + name: ref.name, + path: relPath, + isDir: false, + fileRefId: ref._id + }) } - if (msg.includes('not found') || msg.includes('does not appear to be a git repository')) { - return { success: false, message: 'not_found', detail: 'Project not found. Check the URL and ensure you have access.' } + + for (const sub of f.folders || []) { + const relPath = prefix + sub.name + '/' + const children = walkFolder(sub, relPath) + nodes.push({ + name: sub.name, + path: relPath, + isDir: true, + children, + folderId: sub._id + }) } - return { success: false, message: 'error', detail: msg } - } - console.log('[overleaf:cloneWithAuth] Auth verified. Storing credentials and cloning...') + return nodes + } - // Step 2: Credentials work — store in keychain if requested - if (remember) { - await storeCredentials('git', token) - console.log('[overleaf:cloneWithAuth] Token saved to Keychain') + const files: SocketFileNode[] = [] + const rootFolderId = folders[0]?._id || '' + for (const root of folders) { + files.push(...walkFolder(root, '')) } - // Step 3: Clone using keychain credentials - const result = await gitSpawn(['clone', repoUrl, dest]) - if (result.success) { - return { success: true, message: 'ok', detail: '' } - } else { - return { success: false, message: 'clone_failed', detail: result.message } + return { files, docPathMap, pathDocMap, fileRefs, rootFolderId } +} + +ipcMain.handle('ot:connect', async (_e, projectId: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + + try { + overleafSock = new OverleafSocket() + + // Relay events to renderer + overleafSock.on('connectionState', (state: string) => { + mainWindow?.webContents.send('ot:connectionState', state) + }) + + // otUpdateApplied: server acknowledges our op (ack signal for OT client) + overleafSock.on('serverEvent', (name: string, args: unknown[]) => { + if (name === 'otUpdateApplied') { + const update = args[0] as { doc?: string; v?: number } | undefined + if (update?.doc) { + mainWindow?.webContents.send('ot:ack', { docId: update.doc }) + } + } + }) + + overleafSock.on('docRejoined', (docId: string, result: JoinDocResult) => { + mainWindow?.webContents.send('ot:docRejoined', { + docId, + content: result.docLines.join('\n'), + version: result.version + }) + }) + + const projectResult = await overleafSock.connect(projectId, overleafSessionCookie) + const { files, docPathMap, pathDocMap, fileRefs, rootFolderId } = walkRootFolder(projectResult.project.rootFolder) + + // Set up compilation manager + compilationManager = new CompilationManager(projectId, overleafSessionCookie) + + // Set up file sync bridge for bidirectional sync + const tmpDir = compilationManager.dir + fileSyncBridge = new FileSyncBridge(overleafSock, tmpDir, docPathMap, pathDocMap, mainWindow!) + fileSyncBridge.start().catch((e) => { + console.log('[ot:connect] fileSyncBridge start error:', e) + }) + + return { + success: true, + files, + project: { + name: projectResult.project.name, + rootDocId: projectResult.project.rootDoc_id + }, + docPathMap, + pathDocMap, + fileRefs, + rootFolderId + } + } catch (e) { + console.log('[ot:connect] error:', e) + return { success: false, message: String(e) } } }) -// Check if credentials exist in Keychain -ipcMain.handle('overleaf:check', async () => { - return new Promise<{ loggedIn: boolean; email: string }>((resolve) => { - const proc = spawn('git', ['credential-osxkeychain', 'get']) - let out = '' - proc.stdout?.on('data', (d) => { out += d.toString() }) - proc.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`) - proc.stdin?.end() - proc.on('close', (code) => { - if (code === 0 && out.includes('username=')) { - const match = out.match(/username=(.+)/) - resolve({ loggedIn: true, email: match?.[1]?.trim() ?? '' }) - } else { - resolve({ loggedIn: false, email: '' }) +ipcMain.handle('ot:disconnect', async () => { + await fileSyncBridge?.stop() + fileSyncBridge = null + overleafSock?.disconnect() + overleafSock = null + await compilationManager?.cleanup() + compilationManager = null +}) + +// Track per-doc event handlers for cleanup on leaveDoc +const docEventHandlers = new Map void>() + +ipcMain.handle('ot:joinDoc', async (_e, docId: string) => { + if (!overleafSock) return { success: false, message: 'not_connected' } + + try { + const result = await overleafSock.joinDoc(docId) + const content = (result.docLines || []).join('\n') + + // Update compilation manager with doc content + if (compilationManager && overleafSock.projectData) { + const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder) + const relPath = docPathMap[docId] + if (relPath) { + compilationManager.setDocContent(relPath, content) } + } + + // Notify bridge that editor is taking over this doc + fileSyncBridge?.addEditorDoc(docId) + + // Remove existing handler if rejoining + const existingHandler = docEventHandlers.get(docId) + if (existingHandler) overleafSock.removeListener('serverEvent', existingHandler) + + // Set up relay for remote ops on this doc + const handler = (name: string, args: unknown[]) => { + if (name === 'otUpdateApplied') { + const update = args[0] as { doc?: string; op?: unknown[]; v?: number } | undefined + if (update?.doc === docId && update.op) { + mainWindow?.webContents.send('ot:remoteOp', { + docId: update.doc, + ops: update.op, + version: update.v + }) + } + } + } + docEventHandlers.set(docId, handler) + overleafSock.on('serverEvent', handler) + + return { + success: true, + content, + version: result.version, + ranges: result.ranges + } + } catch (e) { + console.log('[ot:joinDoc] error:', e) + return { success: false, message: String(e) } + } +}) + +ipcMain.handle('ot:leaveDoc', async (_e, docId: string) => { + if (!overleafSock) return + try { + // Remove event handler for this doc + const handler = docEventHandlers.get(docId) + if (handler) { + overleafSock.removeListener('serverEvent', handler) + docEventHandlers.delete(docId) + } + // Bridge takes back OT ownership — do NOT leaveDoc on the socket, + // the bridge keeps the doc joined for sync + fileSyncBridge?.removeEditorDoc(docId) + } catch (e) { + console.log('[ot:leaveDoc] error:', e) + } +}) + +ipcMain.handle('ot:sendOp', async (_e, docId: string, ops: unknown[], version: number, hash: string) => { + if (!overleafSock) return + try { + await overleafSock.applyOtUpdate(docId, ops, version, hash) + } catch (e) { + console.log('[ot:sendOp] error:', e) + } +}) + +// Renderer → bridge: editor content changed (for disk sync) +ipcMain.handle('sync:contentChanged', async (_e, docId: string, content: string) => { + fileSyncBridge?.onEditorContentChanged(docId, content) +}) + +ipcMain.handle('overleaf:listProjects', async () => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + + // POST /api/project returns full project data (lastUpdated, owner, etc.) + const result = await overleafFetch('/api/project', { + method: 'POST', + body: JSON.stringify({ + filters: {}, + page: { size: 200 }, + sort: { by: 'lastUpdated', order: 'desc' } + }) + }) + if (!result.ok) return { success: false, message: `HTTP ${result.status}` } + + const data = result.data as { totalSize?: number; projects?: unknown[] } + const projects = (data.projects || []) as Array<{ + id?: string; _id?: string; name: string; lastUpdated: string + owner?: { firstName: string; lastName: string; email?: string } + lastUpdatedBy?: { firstName: string; lastName: string; email?: string } | null + accessLevel?: string + source?: string + }> + + return { + success: true, + projects: projects.map((p) => ({ + id: p.id || p._id || '', + name: p.name, + lastUpdated: p.lastUpdated, + owner: p.owner ? { firstName: p.owner.firstName, lastName: p.owner.lastName, email: p.owner.email } : undefined, + lastUpdatedBy: p.lastUpdatedBy ? { firstName: p.lastUpdatedBy.firstName, lastName: p.lastUpdatedBy.lastName } : null, + accessLevel: p.accessLevel || 'unknown', + source: p.source || '' + })) + } +}) + +ipcMain.handle('overleaf:createProject', async (_e, name: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + const result = await overleafFetch('/api/project/new', { + method: 'POST', + body: JSON.stringify({ projectName: name }) + }) + if (!result.ok) return { success: false, message: `HTTP ${result.status}` } + const data = result.data as { project_id?: string; _id?: string } + return { success: true, projectId: data.project_id || data._id } +}) + +ipcMain.handle('overleaf:uploadProject', async () => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: 'Upload Project (.zip)', + filters: [{ name: 'ZIP Archives', extensions: ['zip'] }], + properties: ['openFile'] + }) + if (canceled || filePaths.length === 0) return { success: false, message: 'cancelled' } + + const zipPath = filePaths[0] + const zipData = await readFile(zipPath) + const fileName = basename(zipPath) + + // Multipart upload + const boundary = '----FormBoundary' + Math.random().toString(36).slice(2) + const header = `--${boundary}\r\nContent-Disposition: form-data; name="qqfile"; filename="${fileName}"\r\nContent-Type: application/zip\r\n\r\n` + const footer = `\r\n--${boundary}--\r\n` + const headerBuf = Buffer.from(header) + const footerBuf = Buffer.from(footer) + const body = Buffer.concat([headerBuf, zipData, footerBuf]) + + return new Promise((resolve) => { + const req = net.request({ + method: 'POST', + url: 'https://www.overleaf.com/api/project/new/upload' }) - proc.on('error', () => { - resolve({ loggedIn: false, email: '' }) + req.setHeader('Cookie', overleafSessionCookie) + req.setHeader('Content-Type', `multipart/form-data; boundary=${boundary}`) + req.setHeader('User-Agent', 'Mozilla/5.0') + if (overleafCsrfToken) req.setHeader('x-csrf-token', overleafCsrfToken) + + let resBody = '' + req.on('response', (res) => { + res.on('data', (chunk) => { resBody += chunk.toString() }) + res.on('end', () => { + try { + const data = JSON.parse(resBody) as { success?: boolean; project_id?: string } + if (data.success !== false && data.project_id) { + resolve({ success: true, projectId: data.project_id }) + } else { + resolve({ success: false, message: 'Upload failed' }) + } + } catch { + resolve({ success: false, message: 'Invalid response' }) + } + }) }) + req.on('error', (e) => resolve({ success: false, message: String(e) })) + req.write(body) + req.end() }) }) -// Remove credentials from Keychain -ipcMain.handle('overleaf:logout', async () => { - return new Promise((resolve) => { - const proc = spawn('git', ['credential-osxkeychain', 'erase']) - proc.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`) - proc.stdin?.end() - proc.on('close', () => resolve()) +// ── File Operations via Overleaf REST API ────────────────────── + +ipcMain.handle('overleaf:renameEntity', async (_e, projectId: string, entityType: string, entityId: string, newName: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + const result = await overleafFetch(`/project/${projectId}/${entityType}/${entityId}/rename`, { + method: 'POST', + body: JSON.stringify({ name: newName }) }) + return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) -// Git operations for existing repos — use osxkeychain -ipcMain.handle('git:pull', async (_e, cwd: string) => { - return gitSpawn(['pull'], cwd) +ipcMain.handle('overleaf:deleteEntity', async (_e, projectId: string, entityType: string, entityId: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + const result = await overleafFetch(`/project/${projectId}/${entityType}/${entityId}`, { + method: 'DELETE' + }) + return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` } }) -ipcMain.handle('git:push', async (_e, cwd: string) => { - const add = await gitSpawn(['add', '-A'], cwd) - if (!add.success) return add - await gitSpawn(['commit', '-m', `Sync from ClaudeTeX ${new Date().toISOString()}`], cwd) - return gitSpawn(['push'], cwd) +ipcMain.handle('overleaf:createDoc', async (_e, projectId: string, parentFolderId: string, name: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + const result = await overleafFetch(`/project/${projectId}/doc`, { + method: 'POST', + body: JSON.stringify({ name, parent_folder_id: parentFolderId }) + }) + return { success: result.ok, data: result.data, message: result.ok ? '' : `HTTP ${result.status}` } +}) + +ipcMain.handle('overleaf:createFolder', async (_e, projectId: string, parentFolderId: string, name: string) => { + if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' } + const result = await overleafFetch(`/project/${projectId}/folder`, { + method: 'POST', + body: JSON.stringify({ name, parent_folder_id: parentFolderId }) + }) + return { success: result.ok, data: result.data, message: result.ok ? '' : `HTTP ${result.status}` } }) -ipcMain.handle('git:status', async (_e, cwd: string) => { - const result = await gitSpawn(['status', '--porcelain'], cwd) - return { isGit: result.success, status: result.message } +// Fetch comment ranges from ALL docs (for ReviewPanel) +ipcMain.handle('ot:fetchAllCommentContexts', async () => { + if (!overleafSock?.projectData) return { success: false } + + const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder) + const contexts: Record = {} + + for (const [docId, relPath] of Object.entries(docPathMap)) { + try { + const alreadyJoined = docEventHandlers.has(docId) + const result = await overleafSock.joinDoc(docId) + if (result.ranges?.comments) { + for (const c of result.ranges.comments) { + if (c.op?.t) { + contexts[c.op.t] = { file: relPath, text: c.op.c || '', pos: c.op.p || 0 } + } + } + } + if (!alreadyJoined) { + await overleafSock.leaveDoc(docId) + } + } catch (e) { + console.log(`[fetchCommentContexts] failed for ${relPath}:`, e) + } + } + + return { success: true, contexts } }) -// ── Shell: open external ───────────────────────────────────────── +ipcMain.handle('overleaf:socketCompile', async (_e, mainTexRelPath: string) => { + if (!compilationManager || !overleafSock?.projectData) { + return { success: false, log: 'No compilation manager or not connected', pdfPath: '' } + } + + const { docPathMap, fileRefs } = walkRootFolder(overleafSock.projectData.project.rootFolder) + + // Bridge already keeps all docs synced to disk. Sync content to compilation manager. + if (fileSyncBridge) { + for (const [docId, relPath] of Object.entries(docPathMap)) { + const content = fileSyncBridge.getDocContent(relPath) + if (content !== undefined) { + compilationManager.setDocContent(relPath, content) + } + } + } else { + // Fallback: fetch docs from socket if bridge isn't available + const allDocIds = Object.keys(docPathMap) + for (const docId of allDocIds) { + const relPath = docPathMap[docId] + if (docEventHandlers.has(docId) && compilationManager.hasDoc(relPath)) continue + try { + const alreadyJoined = docEventHandlers.has(docId) + const result = await overleafSock.joinDoc(docId) + const content = (result.docLines || []).join('\n') + compilationManager.setDocContent(relPath, content) + if (!alreadyJoined) { + await overleafSock.leaveDoc(docId) + } + } catch (e) { + console.log(`[socketCompile] failed to fetch doc ${relPath}:`, e) + } + } + } + + // Download all binary files (images, .bst, etc.) + await compilationManager.syncBinaries(fileRefs) + + return compilationManager.compile(mainTexRelPath, (data) => { + mainWindow?.webContents.send('latex:log', data) + }) +}) + +/// ── Shell: open external ───────────────────────────────────────── ipcMain.handle('shell:openExternal', async (_e, url: string) => { await shell.openExternal(url) @@ -559,12 +968,18 @@ ipcMain.handle('shell:showInFinder', async (_e, path: string) => { // ── App Lifecycle ──────────────────────────────────────────────── -app.whenReady().then(createWindow) +app.whenReady().then(async () => { + createWindow() + sessionLoadPromise = loadOverleafSession() + +}) app.on('window-all-closed', () => { ptyInstance?.kill() - fileWatcher?.close() - compileProcess?.kill() + fileSyncBridge?.stop() + fileSyncBridge = null + overleafSock?.disconnect() + compilationManager?.cleanup() app.quit() }) diff --git a/src/main/otClient.ts b/src/main/otClient.ts new file mode 100644 index 0000000..7985c66 --- /dev/null +++ b/src/main/otClient.ts @@ -0,0 +1,131 @@ +// OT state machine for main process (mirror of renderer otClient) +import type { OtOp } from './otTypes' +import { transformOps } from './otTransform' + +export type SendFn = (ops: OtOp[], version: number) => void +export type ApplyFn = (ops: OtOp[]) => void + +interface OtState { + name: 'synchronized' | 'awaitingConfirm' | 'awaitingWithBuffer' + inflight: OtOp[] | null + buffer: OtOp[] | null + version: number +} + +export class OtClient { + private state: OtState + private sendFn: SendFn + private applyFn: ApplyFn + + constructor(version: number, sendFn: SendFn, applyFn: ApplyFn) { + this.state = { name: 'synchronized', inflight: null, buffer: null, version } + this.sendFn = sendFn + this.applyFn = applyFn + } + + get version(): number { + return this.state.version + } + + get stateName(): string { + return this.state.name + } + + onLocalOps(ops: OtOp[]) { + if (ops.length === 0) return + + switch (this.state.name) { + case 'synchronized': + this.state = { + name: 'awaitingConfirm', + inflight: ops, + buffer: null, + version: this.state.version + } + this.sendFn(ops, this.state.version) + break + + case 'awaitingConfirm': + this.state = { + name: 'awaitingWithBuffer', + inflight: this.state.inflight, + buffer: ops, + version: this.state.version + } + break + + case 'awaitingWithBuffer': + this.state = { + ...this.state, + buffer: [...(this.state.buffer || []), ...ops] + } + break + } + } + + onAck() { + switch (this.state.name) { + case 'awaitingConfirm': + this.state = { + name: 'synchronized', + inflight: null, + buffer: null, + version: this.state.version + 1 + } + break + + case 'awaitingWithBuffer': { + const bufferOps = this.state.buffer || [] + this.state = { + name: 'awaitingConfirm', + inflight: bufferOps, + buffer: null, + version: this.state.version + 1 + } + this.sendFn(bufferOps, this.state.version) + break + } + + case 'synchronized': + console.warn('[OtClient:main] unexpected ack in synchronized state') + break + } + } + + onRemoteOps(ops: OtOp[], newVersion: number) { + switch (this.state.name) { + case 'synchronized': + this.state = { ...this.state, version: newVersion } + this.applyFn(ops) + break + + case 'awaitingConfirm': { + const { left: transformedRemote, right: transformedInflight } = transformOps(ops, this.state.inflight || []) + this.state = { + ...this.state, + inflight: transformedInflight, + version: newVersion + } + this.applyFn(transformedRemote) + break + } + + case 'awaitingWithBuffer': { + const { left: remoteAfterInflight, right: inflightAfterRemote } = transformOps(ops, this.state.inflight || []) + const { left: remoteAfterBuffer, right: bufferAfterRemote } = transformOps(remoteAfterInflight, this.state.buffer || []) + this.state = { + ...this.state, + inflight: inflightAfterRemote, + buffer: bufferAfterRemote, + version: newVersion + } + this.applyFn(remoteAfterBuffer) + break + } + } + } + + reset(version: number) { + this.state = { name: 'synchronized', inflight: null, buffer: null, version } + } +} diff --git a/src/main/otTransform.ts b/src/main/otTransform.ts new file mode 100644 index 0000000..0d05450 --- /dev/null +++ b/src/main/otTransform.ts @@ -0,0 +1,117 @@ +// OT transform functions for main process (mirror of renderer transform) +import type { OtOp } from './otTypes' +import { isInsert, isDelete, isComment } from './otTypes' + +export function transformOps( + ops1: OtOp[], + ops2: OtOp[] +): { left: OtOp[]; right: OtOp[] } { + let right = ops2 + + const newLeft: OtOp[] = [] + for (const op1 of ops1) { + let transformed = op1 + const newRight: OtOp[] = [] + for (const op2 of right) { + const { left: tl, right: tr } = transformOp(transformed, op2) + transformed = tl + newRight.push(tr) + } + newLeft.push(transformed) + right = newRight + } + + return { left: newLeft, right } +} + +function transformOp(op1: OtOp, op2: OtOp): { left: OtOp; right: OtOp } { + if (isInsert(op1) && isInsert(op2)) { + if (op1.p <= op2.p) { + return { left: op1, right: { ...op2, p: op2.p + op1.i.length } } + } else { + return { left: { ...op1, p: op1.p + op2.i.length }, right: op2 } + } + } + + if (isInsert(op1) && isDelete(op2)) { + if (op1.p <= op2.p) { + return { left: op1, right: { ...op2, p: op2.p + op1.i.length } } + } else if (op1.p >= op2.p + op2.d.length) { + return { left: { ...op1, p: op1.p - op2.d.length }, right: op2 } + } else { + return { left: { ...op1, p: op2.p }, right: op2 } + } + } + + if (isDelete(op1) && isInsert(op2)) { + if (op2.p <= op1.p) { + return { left: { ...op1, p: op1.p + op2.i.length }, right: op2 } + } else if (op2.p >= op1.p + op1.d.length) { + return { left: op1, right: { ...op2, p: op2.p - op1.d.length } } + } else { + return { left: op1, right: { ...op2, p: op2.p - op1.d.length } } + } + } + + if (isDelete(op1) && isDelete(op2)) { + if (op1.p >= op2.p + op2.d.length) { + return { + left: { ...op1, p: op1.p - op2.d.length }, + right: { ...op2, p: op2.p } + } + } else if (op2.p >= op1.p + op1.d.length) { + return { + left: op1, + right: { ...op2, p: op2.p - op1.d.length } + } + } else { + const overlapStart = Math.max(0, op2.p - op1.p) + const overlapEnd = Math.min(op1.d.length, op2.p + op2.d.length - op1.p) + let newOp1Text = op1.d + if (overlapEnd > overlapStart) { + newOp1Text = op1.d.slice(0, overlapStart) + op1.d.slice(overlapEnd) + } + + const overlapStart2 = Math.max(0, op1.p - op2.p) + const overlapEnd2 = Math.min(op2.d.length, op1.p + op1.d.length - op2.p) + let newOp2Text = op2.d + if (overlapEnd2 > overlapStart2) { + newOp2Text = op2.d.slice(0, overlapStart2) + op2.d.slice(overlapEnd2) + } + + const newP1 = op1.p <= op2.p ? op1.p : op1.p - (overlapEnd2 - overlapStart2) + const newP2 = op2.p <= op1.p ? op2.p : op2.p - (overlapEnd - overlapStart) + + return { + left: newOp1Text ? { d: newOp1Text, p: Math.max(0, newP1) } : { d: '', p: 0 }, + right: newOp2Text ? { d: newOp2Text, p: Math.max(0, newP2) } : { d: '', p: 0 } + } + } + } + + if (isComment(op1) || isComment(op2)) { + if (isComment(op1)) { + if (isInsert(op2) && op2.p <= op1.p) { + return { left: { ...op1, p: op1.p + op2.i.length }, right: op2 } + } + if (isDelete(op2) && op2.p < op1.p) { + const shift = Math.min(op2.d.length, op1.p - op2.p) + return { left: { ...op1, p: op1.p - shift }, right: op2 } + } + } + + if (isComment(op2)) { + if (isInsert(op1) && op1.p <= op2.p) { + return { left: op1, right: { ...op2, p: op2.p + op1.i.length } } + } + if (isDelete(op1) && op1.p < op2.p) { + const shift = Math.min(op1.d.length, op2.p - op1.p) + return { left: op1, right: { ...op2, p: op2.p - shift } } + } + } + + return { left: op1, right: op2 } + } + + return { left: op1, right: op2 } +} diff --git a/src/main/otTypes.ts b/src/main/otTypes.ts new file mode 100644 index 0000000..8e9df9f --- /dev/null +++ b/src/main/otTypes.ts @@ -0,0 +1,31 @@ +// OT type definitions for main process (mirror of renderer types) + +export interface InsertOp { + i: string + p: number +} + +export interface DeleteOp { + d: string + p: number +} + +export interface CommentOp { + c: string + p: number + t: string +} + +export type OtOp = InsertOp | DeleteOp | CommentOp + +export function isInsert(op: OtOp): op is InsertOp { + return 'i' in op +} + +export function isDelete(op: OtOp): op is DeleteOp { + return 'd' in op +} + +export function isComment(op: OtOp): op is CommentOp { + return 'c' in op +} diff --git a/src/main/overleafProtocol.ts b/src/main/overleafProtocol.ts new file mode 100644 index 0000000..49b06d7 --- /dev/null +++ b/src/main/overleafProtocol.ts @@ -0,0 +1,95 @@ +// Socket.IO v0.9 protocol encoding/decoding + +export interface ParsedMessage { + type: 'disconnect' | 'connect' | 'heartbeat' | 'event' | 'ack' | 'error' | 'noop' + id?: number + data?: unknown + name?: string + args?: unknown[] +} + +/** + * Parse a Socket.IO v0.9 message frame. + * + * Frame format: + * 0:: disconnect + * 1:: connect + * 2:: heartbeat + * 5:::{"name":"x","args":[...]} event + * 5:N+::{"name":"x","args":[...]} event with ack request + * 6:::N+[jsonData] ack response + * 8:: noop + */ +export function parseSocketMessage(raw: string): ParsedMessage | null { + if (!raw || raw.length === 0) return null + + const type = raw[0] + + switch (type) { + case '0': + return { type: 'disconnect' } + case '1': + return { type: 'connect' } + case '2': + return { type: 'heartbeat' } + case '8': + return { type: 'noop' } + case '5': { + // Event: 5:::{"name":"x","args":[...]} or 5:N+::{"name":"x","args":[...]} + const ackMatch = raw.match(/^5:(\d+)\+::(.*)$/s) + if (ackMatch) { + try { + const payload = JSON.parse(ackMatch[2]) + return { + type: 'event', + id: parseInt(ackMatch[1]), + name: payload.name, + args: payload.args || [] + } + } catch { + return null + } + } + const evtMatch = raw.match(/^5:::(.*)$/s) + if (evtMatch) { + try { + const payload = JSON.parse(evtMatch[1]) + return { type: 'event', name: payload.name, args: payload.args || [] } + } catch { + return null + } + } + return null + } + case '6': { + // Ack: 6:::N+[jsonData] + const ackMatch = raw.match(/^6:::(\d+)\+([\s\S]*)/) + if (ackMatch) { + try { + const data = JSON.parse(ackMatch[2]) + return { type: 'ack', id: parseInt(ackMatch[1]), data } + } catch { + return { type: 'ack', id: parseInt(ackMatch[1]), data: null } + } + } + return null + } + default: + return null + } +} + +/** Encode a Socket.IO v0.9 event (no ack) */ +export function encodeEvent(name: string, args: unknown[]): string { + return '5:::' + JSON.stringify({ name, args }) +} + +/** Encode a Socket.IO v0.9 event that expects an ack response */ +export function encodeEventWithAck(ackId: number, name: string, args: unknown[]): string { + return `5:${ackId}+::` + JSON.stringify({ name, args }) +} + +/** Encode a heartbeat response */ +export function encodeHeartbeat(): string { + return '2::' +} diff --git a/src/main/overleafSocket.ts b/src/main/overleafSocket.ts new file mode 100644 index 0000000..f825c4c --- /dev/null +++ b/src/main/overleafSocket.ts @@ -0,0 +1,401 @@ +// Persistent Socket.IO v0.9 client for real-time Overleaf collaboration +import { EventEmitter } from 'events' +import WebSocket from 'ws' +import { net } from 'electron' +import { + parseSocketMessage, + encodeEvent, + encodeEventWithAck, + encodeHeartbeat +} from './overleafProtocol' + +export interface JoinProjectResult { + publicId: string + project: { + _id: string + name: string + rootDoc_id: string + rootFolder: RootFolder[] + owner: { _id: string; first_name: string; last_name: string; email: string } + } + permissionsLevel: string +} + +export interface RootFolder { + _id: string + name: string + docs: DocRef[] + fileRefs: FileRef[] + folders: SubFolder[] +} + +export interface SubFolder { + _id: string + name: string + docs: DocRef[] + fileRefs: FileRef[] + folders: SubFolder[] +} + +export interface DocRef { + _id: string + name: string +} + +export interface FileRef { + _id: string + name: string + linkedFileData?: unknown + created: string +} + +export interface CommentOp { + c: string + p: number + t: string +} + +export interface JoinDocResult { + docLines: string[] + version: number + updates: unknown[] + ranges: { + comments: Array<{ id: string; op: CommentOp }> + changes: unknown[] + } +} + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' + +export class OverleafSocket extends EventEmitter { + private ws: WebSocket | null = null + private cookie: string = '' + private projectId: string = '' + private sid: string = '' + private ackId = 0 + private ackCallbacks = new Map void>() + private eventWaiters = new Map void>() + private heartbeatTimer: ReturnType | null = null + private reconnectTimer: ReturnType | null = null + private reconnectAttempt = 0 + private maxReconnectDelay = 30000 + private joinedDocs = new Set() + private _state: ConnectionState = 'disconnected' + private _projectData: JoinProjectResult | null = null + private shouldReconnect = true + + get state(): ConnectionState { + return this._state + } + + get projectData(): JoinProjectResult | null { + return this._projectData + } + + private setState(s: ConnectionState) { + this._state = s + this.emit('connectionState', s) + } + + async connect(projectId: string, cookie: string): Promise { + this.projectId = projectId + this.cookie = cookie + this.shouldReconnect = true + return this.doConnect() + } + + private async doConnect(): Promise { + this.setState('connecting') + + // Step 1: HTTP handshake to get SID + const hsData = await this.handshake() + this.sid = hsData.sid + + // Step 2: Open WebSocket + return new Promise((resolve, reject) => { + const wsUrl = `wss://www.overleaf.com/socket.io/1/websocket/${this.sid}` + this.ws = new WebSocket(wsUrl, { + headers: { Cookie: this.cookie } + }) + + const timeout = setTimeout(() => { + reject(new Error('WebSocket connection timeout')) + this.ws?.close() + }, 30000) + + this.ws.on('open', () => { + // Wait for connect message (1::) then joinProject + }) + + this.ws.on('message', (data: WebSocket.Data) => { + const raw = data.toString() + this.handleMessage(raw, resolve, reject, timeout) + }) + + this.ws.on('error', (err) => { + clearTimeout(timeout) + reject(err) + }) + + this.ws.on('close', () => { + this.stopHeartbeat() + if (this._state === 'connected' && this.shouldReconnect) { + this.scheduleReconnect() + } + }) + }) + } + + private connectResolveFn: ((result: JoinProjectResult) => void) | null = null + private connectRejectFn: ((err: Error) => void) | null = null + private connectTimeout: ReturnType | null = null + + private handleMessage( + raw: string, + connectResolve?: (result: JoinProjectResult) => void, + connectReject?: (err: Error) => void, + connectTimeout?: ReturnType + ) { + const msg = parseSocketMessage(raw) + if (!msg) return + + switch (msg.type) { + case 'connect': + // Server acknowledged connection, now joinProject + this.sendJoinProject(connectResolve, connectReject, connectTimeout) + break + + case 'heartbeat': + this.ws?.send(encodeHeartbeat()) + break + + case 'ack': + if (msg.id !== undefined) { + const cb = this.ackCallbacks.get(msg.id) + if (cb) { + this.ackCallbacks.delete(msg.id) + cb(msg.data) + } + } + break + + case 'event': + if (msg.name) { + // Check if someone is waiting for this event name + const waiter = this.eventWaiters.get(msg.name) + if (waiter) { + this.eventWaiters.delete(msg.name) + waiter(msg.args || []) + } + // Relay real-time events to listeners + this.emit('serverEvent', msg.name, msg.args || []) + + // Handle specific real-time events + if (msg.name === 'otUpdateApplied') { + this.emit('otAck', msg.args?.[0]) + } else if (msg.name === 'otUpdateError') { + this.emit('otError', msg.args?.[0]) + } + } + break + + case 'disconnect': + this.ws?.close() + break + } + } + + private sendJoinProject( + resolve?: (result: JoinProjectResult) => void, + reject?: (err: Error) => void, + timeout?: ReturnType + ) { + // joinProject uses a named event, response comes as joinProjectResponse event + const jpPromise = this.waitForEvent('joinProjectResponse') + + this.ws?.send(encodeEvent('joinProject', [{ project_id: this.projectId }])) + + jpPromise.then((args) => { + if (timeout) clearTimeout(timeout) + + // Find the project data in the response args + let projectResult: JoinProjectResult | null = null + for (const arg of args) { + if (arg && typeof arg === 'object' && 'project' in (arg as object)) { + projectResult = arg as JoinProjectResult + break + } + } + + if (!projectResult) { + reject?.(new Error('joinProject: no project data in response')) + return + } + + this._projectData = projectResult + this.setState('connected') + this.reconnectAttempt = 0 + this.startHeartbeat() + resolve?.(projectResult) + }).catch((err) => { + if (timeout) clearTimeout(timeout) + reject?.(err) + }) + } + + async joinDoc(docId: string): Promise { + const result = await this.emitWithAck('joinDoc', [docId, { encodeRanges: true }]) as unknown[] + this.joinedDocs.add(docId) + + // Ack response format: [error, docLines, version, updates, ranges, pathname] + // First element is error (null = success) + const err = result[0] + if (err) throw new Error(`joinDoc failed: ${JSON.stringify(err)}`) + + const docLines = (result[1] as string[]) || [] + const version = (result[2] as number) || 0 + const updates = (result[3] as unknown[]) || [] + const ranges = (result[4] || { comments: [], changes: [] }) as JoinDocResult['ranges'] + + return { docLines, version, updates, ranges } + } + + async leaveDoc(docId: string): Promise { + await this.emitWithAck('leaveDoc', [docId]) + this.joinedDocs.delete(docId) + } + + async applyOtUpdate(docId: string, ops: unknown[], version: number, hash: string): Promise { + // Fire-and-forget: server responds with otUpdateApplied or otUpdateError event + this.ws?.send(encodeEvent('applyOtUpdate', [docId, { doc: docId, op: ops, v: version, hash, lastV: version }])) + } + + disconnect() { + this.shouldReconnect = false + this.stopHeartbeat() + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.joinedDocs.clear() + this.ackCallbacks.clear() + this.eventWaiters.clear() + this.ws?.close() + this.ws = null + this._projectData = null + this.setState('disconnected') + } + + private async handshake(): Promise<{ sid: string; setCookies: string[] }> { + return new Promise((resolve, reject) => { + const url = `https://www.overleaf.com/socket.io/1/?t=${Date.now()}&projectId=${this.projectId}` + const req = net.request(url) + req.setHeader('Cookie', this.cookie) + req.setHeader('User-Agent', 'Mozilla/5.0') + + let body = '' + const setCookies: string[] = [] + + req.on('response', (res) => { + const rawHeaders = res.headers['set-cookie'] + if (rawHeaders) { + if (Array.isArray(rawHeaders)) { + setCookies.push(...rawHeaders) + } else { + setCookies.push(rawHeaders) + } + } + res.on('data', (chunk) => { body += chunk.toString() }) + res.on('end', () => { + const sid = body.split(':')[0] + if (!sid) { + reject(new Error('handshake: no SID in response')) + return + } + // Merge GCLB cookies into our cookie string + for (const sc of setCookies) { + const part = sc.split(';')[0] + if (part && !this.cookie.includes(part)) { + this.cookie += '; ' + part + } + } + resolve({ sid, setCookies }) + }) + }) + req.on('error', reject) + req.end() + }) + } + + private emitWithAck(name: string, args: unknown[]): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket not connected')) + return + } + this.ackId++ + const id = this.ackId + const timer = setTimeout(() => { + this.ackCallbacks.delete(id) + reject(new Error(`ack timeout for ${name}`)) + }, 30000) + + this.ackCallbacks.set(id, (data) => { + clearTimeout(timer) + resolve(data) + }) + + this.ws.send(encodeEventWithAck(id, name, args)) + }) + } + + private waitForEvent(name: string): Promise { + return new Promise((resolve) => { + this.eventWaiters.set(name, resolve) + }) + } + + private startHeartbeat() { + this.stopHeartbeat() + this.heartbeatTimer = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(encodeHeartbeat()) + } + }, 25000) + } + + private stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + } + + private scheduleReconnect() { + this.setState('reconnecting') + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), this.maxReconnectDelay) + this.reconnectAttempt++ + + console.log(`[OverleafSocket] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`) + + this.reconnectTimer = setTimeout(async () => { + try { + await this.doConnect() + // Re-join docs + for (const docId of this.joinedDocs) { + try { + const result = await this.joinDoc(docId) + this.emit('docRejoined', docId, result) + } catch (e) { + console.log(`[OverleafSocket] failed to rejoin doc ${docId}:`, e) + } + } + } catch (e) { + console.log('[OverleafSocket] reconnect failed:', e) + if (this.shouldReconnect) { + this.scheduleReconnect() + } + } + }, delay) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ab360c0..5dcbbff 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,32 +1,12 @@ import { contextBridge, ipcRenderer } from 'electron' +import { createHash } from 'crypto' const api = { // File system - openProject: () => ipcRenderer.invoke('dialog:openProject'), - selectSaveDir: () => ipcRenderer.invoke('dialog:selectSaveDir'), - readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path), readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path), - findMainTex: (dir: string) => ipcRenderer.invoke('fs:findMainTex', dir) as Promise, readBinary: (path: string) => ipcRenderer.invoke('fs:readBinary', path) as Promise, - writeFile: (path: string, content: string) => ipcRenderer.invoke('fs:writeFile', path, content), - createFile: (dir: string, name: string) => ipcRenderer.invoke('fs:createFile', dir, name), - createDir: (dir: string, name: string) => ipcRenderer.invoke('fs:createDir', dir, name), - renameFile: (oldPath: string, newPath: string) => ipcRenderer.invoke('fs:rename', oldPath, newPath), - deleteFile: (path: string) => ipcRenderer.invoke('fs:delete', path), - fileStat: (path: string) => ipcRenderer.invoke('fs:stat', path), - - // File watcher - watchStart: (path: string) => ipcRenderer.invoke('watcher:start', path), - watchStop: () => ipcRenderer.invoke('watcher:stop'), - onWatchChange: (cb: (data: { event: string; path: string }) => void) => { - const handler = (_e: Electron.IpcRendererEvent, data: { event: string; path: string }) => cb(data) - ipcRenderer.on('watcher:change', handler) - return () => ipcRenderer.removeListener('watcher:change', handler) - }, // LaTeX - compile: (path: string) => ipcRenderer.invoke('latex:compile', path), - getPdfPath: (texPath: string) => ipcRenderer.invoke('latex:getPdfPath', texPath), onCompileLog: (cb: (log: string) => void) => { const handler = (_e: Electron.IpcRendererEvent, log: string) => cb(log) ipcRenderer.on('latex:log', handler) @@ -49,24 +29,120 @@ const api = { return () => ipcRenderer.removeListener('pty:exit', handler) }, - // Overleaf - overleafCloneWithAuth: (projectId: string, dest: string, token: string, remember: boolean) => - ipcRenderer.invoke('overleaf:cloneWithAuth', projectId, dest, token, remember) as Promise<{ success: boolean; message: string; detail: string }>, - overleafCheck: () => ipcRenderer.invoke('overleaf:check') as Promise<{ loggedIn: boolean; email: string }>, - overleafLogout: () => ipcRenderer.invoke('overleaf:logout'), - gitPull: (cwd: string) => ipcRenderer.invoke('git:pull', cwd), - gitPush: (cwd: string) => ipcRenderer.invoke('git:push', cwd), - gitStatus: (cwd: string) => ipcRenderer.invoke('git:status', cwd), - // SyncTeX synctexEdit: (pdfPath: string, page: number, x: number, y: number) => ipcRenderer.invoke('synctex:editFromPdf', pdfPath, page, x, y) as Promise<{ file: string; line: number } | null>, - synctexView: (texPath: string, line: number, pdfPath: string) => - ipcRenderer.invoke('synctex:viewFromSource', texPath, line, pdfPath) as Promise<{ page: number; x: number; y: number } | null>, - // LaTeX package management - installTexPackages: (packages: string[]) => - ipcRenderer.invoke('latex:installPackages', packages) as Promise<{ success: boolean; message: string; packages?: string[] }>, + // Overleaf web session (comments) + overleafWebLogin: () => ipcRenderer.invoke('overleaf:webLogin') as Promise<{ success: boolean }>, + overleafHasWebSession: () => ipcRenderer.invoke('overleaf:hasWebSession') as Promise<{ loggedIn: boolean }>, + overleafGetThreads: (projectId: string) => + ipcRenderer.invoke('overleaf:getThreads', projectId) as Promise<{ success: boolean; threads?: Record; message?: string }>, + overleafReplyThread: (projectId: string, threadId: string, content: string) => + ipcRenderer.invoke('overleaf:replyThread', projectId, threadId, content) as Promise<{ success: boolean }>, + overleafResolveThread: (projectId: string, threadId: string) => + ipcRenderer.invoke('overleaf:resolveThread', projectId, threadId) as Promise<{ success: boolean }>, + overleafReopenThread: (projectId: string, threadId: string) => + ipcRenderer.invoke('overleaf:reopenThread', projectId, threadId) as Promise<{ success: boolean }>, + overleafDeleteMessage: (projectId: string, threadId: string, messageId: string) => + ipcRenderer.invoke('overleaf:deleteMessage', projectId, threadId, messageId) as Promise<{ success: boolean }>, + overleafEditMessage: (projectId: string, threadId: string, messageId: string, content: string) => + ipcRenderer.invoke('overleaf:editMessage', projectId, threadId, messageId, content) as Promise<{ success: boolean }>, + overleafDeleteThread: (projectId: string, docId: string, threadId: string) => + ipcRenderer.invoke('overleaf:deleteThread', projectId, docId, threadId) as Promise<{ success: boolean }>, + overleafAddComment: (projectId: string, docId: string, pos: number, text: string, content: string) => + ipcRenderer.invoke('overleaf:addComment', projectId, docId, pos, text, content) as Promise<{ success: boolean; threadId?: string; message?: string }>, + + // OT / Socket mode + otConnect: (projectId: string) => + ipcRenderer.invoke('ot:connect', projectId) as Promise<{ + success: boolean + files?: unknown[] + project?: { name: string; rootDocId: string } + docPathMap?: Record + pathDocMap?: Record + fileRefs?: Array<{ id: string; path: string }> + rootFolderId?: string + message?: string + }>, + otDisconnect: () => ipcRenderer.invoke('ot:disconnect'), + otJoinDoc: (docId: string) => + ipcRenderer.invoke('ot:joinDoc', docId) as Promise<{ + success: boolean + content?: string + version?: number + ranges?: { comments: Array<{ id: string; op: { c: string; p: number; t: string } }>; changes: unknown[] } + message?: string + }>, + otLeaveDoc: (docId: string) => ipcRenderer.invoke('ot:leaveDoc', docId), + otSendOp: (docId: string, ops: unknown[], version: number, hash: string) => + ipcRenderer.invoke('ot:sendOp', docId, ops, version, hash), + otFetchAllCommentContexts: () => + ipcRenderer.invoke('ot:fetchAllCommentContexts') as Promise<{ + success: boolean + contexts?: Record + }>, + onOtRemoteOp: (cb: (data: { docId: string; ops: unknown[]; version: number }) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; ops: unknown[]; version: number }) => cb(data) + ipcRenderer.on('ot:remoteOp', handler) + return () => ipcRenderer.removeListener('ot:remoteOp', handler) + }, + onOtAck: (cb: (data: { docId: string }) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { docId: string }) => cb(data) + ipcRenderer.on('ot:ack', handler) + return () => ipcRenderer.removeListener('ot:ack', handler) + }, + onOtConnectionState: (cb: (state: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, state: string) => cb(state) + ipcRenderer.on('ot:connectionState', handler) + return () => ipcRenderer.removeListener('ot:connectionState', handler) + }, + onOtDocRejoined: (cb: (data: { docId: string; content: string; version: number }) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: string; version: number }) => cb(data) + ipcRenderer.on('ot:docRejoined', handler) + return () => ipcRenderer.removeListener('ot:docRejoined', handler) + }, + overleafListProjects: () => + ipcRenderer.invoke('overleaf:listProjects') as Promise<{ + success: boolean + projects?: Array<{ + id: string; name: string; lastUpdated: string + owner?: { firstName: string; lastName: string; email?: string } + lastUpdatedBy?: { firstName: string; lastName: string } | null + accessLevel?: string; source?: string + }> + message?: string + }>, + overleafCreateProject: (name: string) => + ipcRenderer.invoke('overleaf:createProject', name) as Promise<{ + success: boolean; projectId?: string; message?: string + }>, + overleafUploadProject: () => + ipcRenderer.invoke('overleaf:uploadProject') as Promise<{ + success: boolean; projectId?: string; message?: string + }>, + overleafSocketCompile: (mainTexRelPath: string) => + ipcRenderer.invoke('overleaf:socketCompile', mainTexRelPath) as Promise<{ + success: boolean; log: string; pdfPath: string + }>, + overleafRenameEntity: (projectId: string, entityType: string, entityId: string, newName: string) => + ipcRenderer.invoke('overleaf:renameEntity', projectId, entityType, entityId, newName) as Promise<{ success: boolean; message?: string }>, + overleafDeleteEntity: (projectId: string, entityType: string, entityId: string) => + ipcRenderer.invoke('overleaf:deleteEntity', projectId, entityType, entityId) as Promise<{ success: boolean; message?: string }>, + overleafCreateDoc: (projectId: string, parentFolderId: string, name: string) => + ipcRenderer.invoke('overleaf:createDoc', projectId, parentFolderId, name) as Promise<{ success: boolean; data?: unknown; message?: string }>, + overleafCreateFolder: (projectId: string, parentFolderId: string, name: string) => + ipcRenderer.invoke('overleaf:createFolder', projectId, parentFolderId, name) as Promise<{ success: boolean; data?: unknown; message?: string }>, + 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) + ipcRenderer.on('sync:externalEdit', handler) + return () => ipcRenderer.removeListener('sync:externalEdit', handler) + }, + syncContentChanged: (docId: string, content: string) => + ipcRenderer.invoke('sync:contentChanged', docId, content), // Shell openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index a0fb0f9..9ecaefc 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -151,6 +151,7 @@ html, body, #root { .main-content { flex: 1; overflow: hidden; + display: flex; } /* ── Resize Handles ──────────────────────────────────────────── */ @@ -402,6 +403,19 @@ html, body, #root { margin: 4px 0; } +.main-doc-badge { + display: inline-block; + font-size: 9px; + padding: 1px 4px; + margin-left: 6px; + border-radius: 3px; + background: var(--accent); + color: white; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.5px; +} + /* ── Modal ────────────────────────────────────────────────────── */ .modal-overlay { @@ -718,6 +732,337 @@ html, body, #root { white-space: pre-wrap; } +/* ── Projects Page ──────────────────────────────────────────── */ + +.projects-page { + height: 100%; + background: var(--bg-primary); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.projects-drag-bar { + height: 40px; + -webkit-app-region: drag; + flex-shrink: 0; +} + +.projects-container { + flex: 1; + max-width: 860px; + width: 100%; + margin: 0 auto; + padding: 0 32px 32px; + overflow-y: auto; +} + +.projects-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.projects-header h1 { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); +} + +.projects-header-actions { + display: flex; + gap: 8px; +} + +.projects-toolbar { + display: flex; + gap: 8px; + margin-bottom: 16px; + align-items: center; +} + +.projects-search { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-primary); + font-size: 13px; + color: var(--text-primary); + font-family: var(--font-sans); + outline: none; +} +.projects-search:focus { + border-color: var(--accent); +} + +.btn-sm { + padding: 6px 14px; + font-size: 12px; +} + +.projects-list { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.projects-table-header { + display: flex; + align-items: center; + padding: 8px 16px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + user-select: none; +} +.projects-table-header span { + cursor: pointer; +} +.projects-table-header span:hover { + color: var(--text-primary); +} + +.projects-col-name { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} +.projects-col-owner { + width: 140px; + flex-shrink: 0; + font-size: 12px; + color: var(--text-muted); +} +.projects-col-updated { + width: 160px; + flex-shrink: 0; + font-size: 12px; + color: var(--text-muted); + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.projects-item { + display: flex; + align-items: center; + padding: 10px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.projects-item:last-child { + border-bottom: none; +} +.projects-item:hover { + background: var(--bg-secondary); +} + +.projects-item .projects-col-name { + display: flex; + align-items: center; + gap: 8px; +} + +.projects-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.projects-access-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-tertiary, #3a3730); + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.projects-date { + font-size: 12px; + color: var(--text-secondary); +} + +.projects-updated-by { + font-size: 10px; + color: var(--text-muted); +} + +.projects-empty { + padding: 40px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.projects-busy { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 0; + gap: 16px; +} + +/* ── Overleaf Socket / Project Picker ────────────────────────── */ + +.overleaf-dialog-wide { + width: 560px; +} + +.overleaf-back { + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + margin-right: 8px; +} +.overleaf-back:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.overleaf-mode-cards { + display: flex; + gap: 16px; + padding: 16px 0; +} + +.overleaf-mode-card { + flex: 1; + padding: 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; + text-align: center; +} +.overleaf-mode-card:hover { + border-color: var(--accent); + background: var(--bg-secondary); + box-shadow: var(--shadow-sm); +} + +.overleaf-mode-icon { + font-size: 28px; + margin-bottom: 8px; +} + +.overleaf-mode-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.overleaf-mode-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +.overleaf-project-search { + display: flex; + gap: 8px; + margin-bottom: 12px; +} +.overleaf-project-search .modal-input { + flex: 1; +} + +.overleaf-project-list { + max-height: 350px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.overleaf-project-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.overleaf-project-item:last-child { + border-bottom: none; +} +.overleaf-project-item:hover { + background: var(--bg-secondary); +} + +.overleaf-project-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.overleaf-project-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + display: flex; + gap: 8px; +} + +.overleaf-project-loading { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +/* ── Connection Dot ─────────────────────────────────────────── */ + +.connection-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} +.connection-dot-green { + background: var(--success); + box-shadow: 0 0 4px rgba(91, 138, 60, 0.4); +} +.connection-dot-yellow { + background: var(--warning); + box-shadow: 0 0 4px rgba(184, 134, 11, 0.4); + animation: pulse 1.5s ease-in-out infinite; +} +.connection-dot-red { + background: var(--danger); + box-shadow: 0 0 4px rgba(199, 86, 67, 0.4); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.status-connection { + display: flex; + align-items: center; + font-size: 11px; + color: var(--text-muted); +} + /* ── Editor ──────────────────────────────────────────────────── */ .editor-panel { @@ -725,6 +1070,7 @@ html, body, #root { display: flex; flex-direction: column; background: var(--bg-primary); + position: relative; } .editor-empty { @@ -1078,6 +1424,341 @@ html, body, #root { border-radius: var(--radius-sm); } +/* ── Review Panel ────────────────────────────────────────────── */ + +.review-sidebar { + width: 280px; + min-width: 280px; + height: 100%; + border-left: 1px solid var(--border); +} + +.review-panel { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-primary); +} + +.review-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 36px; + padding: 0 10px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.review-header-actions { + display: flex; + gap: 2px; +} + +.review-threads { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.review-empty { + padding: 24px 16px; + text-align: center; + color: var(--text-muted); + font-size: 12px; +} + +.review-login { + padding: 24px 16px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.review-login p { + color: var(--text-muted); + font-size: 12px; +} + +.review-error { + padding: 8px 10px; + background: #FFF0EE; + color: var(--danger); + font-size: 11px; + border-bottom: 1px solid var(--border); +} + +.review-thread { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + background: var(--bg-secondary); + overflow: hidden; +} + +.review-thread-resolved { + opacity: 0.6; +} +.review-thread-highlighted { + border-color: rgba(243, 177, 17, 0.8); + box-shadow: 0 0 0 1px rgba(243, 177, 17, 0.3), 0 1px 4px rgba(243, 177, 17, 0.15); +} + +.review-context { + padding: 6px 8px; + background: #f0ead6; + border-radius: 4px; + margin: 6px 8px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + border-left: 3px solid #b8a070; +} +.review-context:hover { + background: #e8e0c8; +} +.review-context-file { + font-size: 11px; + font-weight: 600; + color: #666; +} +.review-context-text { + font-size: 12px; + color: #8a7050; + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-message { + padding: 8px 10px; +} +.review-message + .review-message { + border-top: 1px solid var(--border); +} +.review-message-first { + background: var(--bg-primary); +} + +.review-message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 3px; +} + +.review-user { + font-size: 11px; + font-weight: 600; + color: var(--accent-blue); + font-family: var(--font-mono); +} + +.review-time { + font-size: 10px; + color: var(--text-muted); +} + +.review-message-content { + font-size: 12px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} + +.review-thread-actions { + display: flex; + gap: 4px; + padding: 4px 8px; + border-top: 1px solid var(--border); + background: var(--bg-tertiary); +} + +.review-action-btn { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + font-family: var(--font-sans); +} +.review-action-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} +.review-action-delete:hover { + color: var(--danger); +} +.review-message-actions-inline { + display: flex; + align-items: center; + gap: 2px; +} +.review-msg-action { + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + padding: 0 3px; + font-size: 12px; + opacity: 0; + transition: opacity 0.15s; + line-height: 1; +} +.review-message:hover .review-msg-action { + opacity: 0.6; +} +.review-msg-action:hover { + opacity: 1 !important; + color: var(--text-primary); +} +.review-msg-delete:hover { + color: var(--danger) !important; +} +.review-edit-inline { + display: flex; + gap: 4px; + margin-top: 4px; +} + +.review-reply { + display: flex; + padding: 6px 8px; + gap: 4px; + border-top: 1px solid var(--border); +} + +.review-reply-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px 8px; + font-size: 12px; + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + outline: none; +} +.review-reply-input:focus { + border-color: var(--accent-blue); +} + +.review-reply-send { + border: none; + background: var(--accent); + color: var(--bg-primary); + font-size: 11px; + padding: 4px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font-sans); +} +.review-reply-send:hover { + background: var(--accent-hover); +} + +.review-section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + padding: 8px 4px 4px; +} + +/* ── Add Comment Overlay ─────────────────────────────────────── */ +.add-comment-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 100; +} +.add-comment-card { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + width: 320px; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); +} +.add-comment-quote { + font-size: 12px; + color: #8a7050; + font-style: italic; + padding: 6px 8px; + background: #f0ead6; + border-left: 3px solid #b8a070; + border-radius: 4px; + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.add-comment-input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-size: 13px; + font-family: var(--font-sans); + background: var(--bg-secondary); + color: var(--text-primary); + outline: none; + resize: none; + box-sizing: border-box; +} +.add-comment-input:focus { + border-color: var(--accent-blue); +} +.add-comment-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 8px; +} +.add-comment-cancel { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-family: var(--font-sans); +} +.add-comment-cancel:hover { + background: var(--bg-hover); +} +.add-comment-submit { + border: none; + background: var(--accent); + color: white; + font-size: 12px; + padding: 4px 14px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-family: var(--font-sans); +} +.add-comment-submit:hover { + background: var(--accent-hover); +} +.add-comment-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ── Terminal ────────────────────────────────────────────────── */ .terminal-panel { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a809ffe..455213b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,66 +1,96 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, Component, type ReactNode } from 'react' import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels' import { useAppStore } from './stores/appStore' -import { showConfirm } from './hooks/useModal' import ModalProvider from './components/ModalProvider' -import OverleafConnect from './components/OverleafConnect' +import ProjectList from './components/ProjectList' import Toolbar from './components/Toolbar' import FileTree from './components/FileTree' import Editor from './components/Editor' import PdfViewer from './components/PdfViewer' import Terminal from './components/Terminal' +import ReviewPanel from './components/ReviewPanel' import StatusBar from './components/StatusBar' +import type { OverleafDocSync } from './ot/overleafSync' + +export const activeDocSyncs = new Map() + +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { return { error } } + render() { + if (this.state.error) { + return ( +
+

Render Error

+

{this.state.error.message}

+
{this.state.error.stack}
+
+ ) + } + return this.props.children + } +} export default function App() { const { - projectPath, - setProjectPath, - setFiles, + screen, + setScreen, + setStatusMessage, showTerminal, showFileTree, - setIsGitRepo, - setGitStatus, - setStatusMessage + showReviewPanel, } = useAppStore() - const refreshFiles = useCallback(async () => { - if (!projectPath) return - const files = await window.api.readDir(projectPath) - setFiles(files) - }, [projectPath, setFiles]) + const [checkingSession, setCheckingSession] = useState(true) - // Load project + // Check session on startup useEffect(() => { - if (!projectPath) return + window.api.overleafHasWebSession().then(({ loggedIn }) => { + setScreen(loggedIn ? 'projects' : 'login') + setCheckingSession(false) + }) + }, [setScreen]) - refreshFiles() - window.api.watchStart(projectPath) + // OT event listeners (always active when in editor) + useEffect(() => { + if (screen !== 'editor') return - // Check git status - window.api.gitStatus(projectPath).then(({ isGit, status }) => { - setIsGitRepo(isGit) - setGitStatus(status) + const unsubRemoteOp = window.api.onOtRemoteOp((data) => { + const sync = activeDocSyncs.get(data.docId) + if (sync) sync.onRemoteOps(data.ops as any, data.version) }) - // Auto-detect main document if not set - if (!useAppStore.getState().mainDocument) { - window.api.findMainTex(projectPath).then((mainTex) => { - if (mainTex) { - useAppStore.getState().setMainDocument(mainTex) - setStatusMessage(`Main document: ${mainTex.split('/').pop()}`) - } - }) - } + const unsubAck = window.api.onOtAck((data) => { + const sync = activeDocSyncs.get(data.docId) + if (sync) sync.onAck() + }) - const unsub = window.api.onWatchChange(() => { - refreshFiles() + const unsubState = window.api.onOtConnectionState((state) => { + useAppStore.getState().setConnectionState(state as any) + if (state === 'reconnecting') setStatusMessage('Reconnecting...') + else if (state === 'connected') setStatusMessage('Connected') + else if (state === 'disconnected') setStatusMessage('Disconnected') + }) + + const unsubRejoined = window.api.onOtDocRejoined((data) => { + const sync = activeDocSyncs.get(data.docId) + if (sync) sync.reset(data.version, data.content) + }) + + // 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) }) return () => { - unsub() - window.api.watchStop() + unsubRemoteOp() + unsubAck() + unsubState() + unsubRejoined() + unsubExternalEdit() } - }, [projectPath, refreshFiles, setIsGitRepo, setGitStatus]) + }, [screen, setStatusMessage]) // Compile log listener useEffect(() => { @@ -73,11 +103,8 @@ export default function App() { // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { + if (screen !== 'editor') return if (e.metaKey || e.ctrlKey) { - if (e.key === 's') { - e.preventDefault() - handleSave() - } if (e.key === 'b') { e.preventDefault() handleCompile() @@ -90,159 +117,170 @@ export default function App() { } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, []) - - const handleSave = async () => { - const { activeTab, fileContents } = useAppStore.getState() - if (!activeTab || !fileContents[activeTab]) return - await window.api.writeFile(activeTab, fileContents[activeTab]) - useAppStore.getState().markModified(activeTab, false) - setStatusMessage('Saved') - } + }, [screen]) const handleCompile = async () => { - const { activeTab, mainDocument } = useAppStore.getState() - const target = mainDocument || activeTab - if (!target || !target.endsWith('.tex')) return - - useAppStore.getState().setCompiling(true) - useAppStore.getState().clearCompileLog() - setStatusMessage('Compiling...') - - const result = await window.api.compile(target) as { - success: boolean; log: string; missingPackages?: string[] + const state = useAppStore.getState() + const mainDoc = state.mainDocument || state.overleafProject?.rootDocId + if (!mainDoc) { + setStatusMessage('No main document set') + return } + const relPath = state.docPathMap[mainDoc] || mainDoc + state.setCompiling(true) + state.clearCompileLog() + setStatusMessage('Compiling...') - console.log('[compile] result.success:', result.success, 'log length:', result.log?.length, 'missingPkgs:', result.missingPackages) + const result = await window.api.overleafSocketCompile(relPath) - // Ensure compile log is populated (fallback if streaming events missed) const storeLog = useAppStore.getState().compileLog - console.log('[compile] storeLog length:', storeLog?.length) if (!storeLog && result.log) { useAppStore.getState().appendCompileLog(result.log) } + if (result.pdfPath) { + useAppStore.getState().setPdfPath(result.pdfPath) + } + useAppStore.getState().setCompiling(false) + setStatusMessage(result.success ? 'Compiled successfully' : 'Compilation had errors — check Log tab') + } - // Always try to load PDF BEFORE setting compiling=false - const pdfPath = await window.api.getPdfPath(target) - console.log('[compile] checking pdfPath:', pdfPath) - try { - const s = await window.api.fileStat(pdfPath) - console.log('[compile] PDF exists, size:', s.size) - useAppStore.getState().setPdfPath(pdfPath) - } catch (err) { - console.log('[compile] PDF not found:', err) + const handleLogin = async () => { + const result = await window.api.overleafWebLogin() + if (result.success) { + setScreen('projects') } + } - // Now signal compilation done - useAppStore.getState().setCompiling(false) + const handleOpenProject = async (pid: string) => { + setScreen('editor') - // Missing packages detected — offer to install - if (result.missingPackages && result.missingPackages.length > 0) { - const pkgs = result.missingPackages - const ok = await showConfirm( - 'Missing LaTeX Packages', - `The following packages are needed:\n\n${pkgs.join(', ')}\n\nInstall them now? (may require your password in terminal)`, - ) - if (ok) { - setStatusMessage(`Installing ${pkgs.join(', ')}...`) - const installResult = await window.api.installTexPackages(pkgs) - if (installResult.success) { - setStatusMessage('Packages installed. Recompiling...') - handleCompile() - return - } else if (installResult.message === 'need_sudo') { - setStatusMessage('Need sudo — installing via terminal...') - useAppStore.getState().showTerminal || useAppStore.getState().toggleTerminal() - await window.api.ptyWrite(`sudo tlmgr install ${pkgs.join(' ')}\n`) - setStatusMessage('Enter your password in terminal, then recompile with Cmd+B') - return - } else { - setStatusMessage('Package install failed') + // Auto-open root doc + const store = useAppStore.getState() + const rootDocId = store.overleafProject?.rootDocId + if (rootDocId) { + const relPath = store.docPathMap[rootDocId] + if (relPath) { + setStatusMessage('Opening root document...') + const result = await window.api.otJoinDoc(rootDocId) + if (result.success && result.content !== undefined) { + const fileName = relPath.split('/').pop() || relPath + useAppStore.getState().setFileContent(relPath, result.content) + useAppStore.getState().openFile(relPath, fileName) + useAppStore.getState().setMainDocument(rootDocId) + if (result.version !== undefined) { + useAppStore.getState().setDocVersion(rootDocId, result.version) + } + if (result.ranges?.comments) { + const contexts: Record = {} + for (const c of result.ranges.comments) { + if (c.op?.t) { + contexts[c.op.t] = { file: relPath, text: c.op.c || '', pos: c.op.p || 0 } + } + } + useAppStore.getState().setCommentContexts(contexts) + } + setStatusMessage(`${store.overleafProject?.name || 'Project'}`) } } } - - if (result.success) { - setStatusMessage('Compiled successfully') - } else { - setStatusMessage('Compilation had errors — check Log tab') - } } - const [showOverleaf, setShowOverleaf] = useState(false) + const handleBackToProjects = async () => { + await window.api.otDisconnect() + activeDocSyncs.forEach((s) => s.destroy()) + activeDocSyncs.clear() + useAppStore.getState().resetEditorState() + setScreen('projects') + } - const handleOpenProject = async () => { - const path = await window.api.openProject() - if (path) setProjectPath(path) + if (checkingSession) { + return ( +
+
+
+
+
+
+ ) } - return ( - <> - - {showOverleaf && ( - { - setShowOverleaf(false) - setProjectPath(path) - }} - onCancel={() => setShowOverleaf(false)} - /> - )} - {!projectPath ? ( + // Login screen + if (screen === 'login') { + return ( + <> +

ClaudeTeX

-

LaTeX editor with AI and Overleaf sync

- -
- ) : ( -
- -
- - {showFileTree && ( - <> - - - - - - )} - - - - - - - - - - - - - - {showTerminal && ( - <> - - - - - - )} - - - -
- + + ) + } + + // Project list screen + if (screen === 'projects') { + return ( + <> + + + + ) + } + + // Editor screen + return ( + + +
+ +
+ + {showFileTree && ( + <> + + + + + + )} + + + + + + + + + + + + + + {showTerminal && ( + <> + + + + + + )} + + + + {showReviewPanel && ( +
+ +
+ )}
- )} - + +
+
) } diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index 30a1e8b..e381802 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection } from '@codemirror/view' import { EditorState } from '@codemirror/state' import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' @@ -7,46 +7,47 @@ import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete' import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' import { stex } from '@codemirror/legacy-modes/mode/stex' import { useAppStore } from '../stores/appStore' +import { + commentHighlights, + commentRangesField, + setCommentRangesEffect, + highlightThreadEffect, + type CommentRange, +} from '../extensions/commentHighlights' +import { addCommentTooltip, setAddCommentCallback } from '../extensions/addCommentTooltip' +import { otSyncExtension, remoteUpdateAnnotation } from '../extensions/otSyncExtension' +import { OverleafDocSync } from '../ot/overleafSync' +import { activeDocSyncs } from '../App' -// Cosmic Latte light theme const cosmicLatteTheme = EditorView.theme({ - '&': { - height: '100%', - fontSize: '13.5px', - backgroundColor: '#FFF8E7' - }, + '&': { height: '100%', fontSize: '13.5px', backgroundColor: '#FFF8E7' }, '.cm-content': { caretColor: '#3B3228', fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace', - color: '#3B3228', - padding: '8px 0' + color: '#3B3228', padding: '8px 0' }, '.cm-cursor': { borderLeftColor: '#3B3228' }, '.cm-activeLine': { backgroundColor: '#F5EDD6' }, '.cm-activeLineGutter': { backgroundColor: '#F5EDD6' }, '.cm-selectionBackground, ::selection': { backgroundColor: '#B8D4E3 !important' }, '.cm-gutters': { - backgroundColor: '#F5EDD6', - color: '#A09880', - border: 'none', - borderRight: '1px solid #D6CEBC', - paddingRight: '8px' + backgroundColor: '#F5EDD6', color: '#A09880', border: 'none', + borderRight: '1px solid #D6CEBC', paddingRight: '8px' }, '.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' }, '.cm-foldGutter': { width: '16px' }, '.cm-matchingBracket': { backgroundColor: '#D4C9A8', outline: 'none' }, - // LaTeX syntax colors — warm earthy palette on Cosmic Latte - '.cm-keyword': { color: '#8B2252' }, // commands: \begin, \section - '.cm-atom': { color: '#B8860B' }, // constants - '.cm-string': { color: '#5B8A3C' }, // strings / text args - '.cm-comment': { color: '#A09880', fontStyle: 'italic' }, // % comments - '.cm-bracket': { color: '#4A6FA5' }, // braces {} - '.cm-tag': { color: '#8B2252' }, // LaTeX tags - '.cm-builtin': { color: '#6B5B3E' }, // builtins - '.ͼ5': { color: '#8B2252' }, // keywords like \begin - '.ͼ6': { color: '#4A6FA5' }, // braces/brackets - '.ͼ7': { color: '#5B8A3C' }, // strings - '.ͼ8': { color: '#A09880' }, // comments + '.cm-keyword': { color: '#8B2252' }, + '.cm-atom': { color: '#B8860B' }, + '.cm-string': { color: '#5B8A3C' }, + '.cm-comment': { color: '#A09880', fontStyle: 'italic' }, + '.cm-bracket': { color: '#4A6FA5' }, + '.cm-tag': { color: '#8B2252' }, + '.cm-builtin': { color: '#6B5B3E' }, + '.ͼ5': { color: '#8B2252' }, + '.ͼ6': { color: '#4A6FA5' }, + '.ͼ7': { color: '#5B8A3C' }, + '.ͼ8': { color: '#A09880' }, }, { dark: false }) export default function Editor() { @@ -55,20 +56,73 @@ export default function Editor() { const { activeTab, fileContents, openTabs, setFileContent, markModified } = useAppStore() const pendingGoTo = useAppStore((s) => s.pendingGoTo) + const commentContexts = useAppStore((s) => s.commentContexts) + const hoveredThreadId = useAppStore((s) => s.hoveredThreadId) + const overleafProjectId = useAppStore((s) => s.overleafProjectId) + const pathDocMap = useAppStore((s) => s.pathDocMap) + const docVersions = useAppStore((s) => s.docVersions) const content = activeTab ? fileContents[activeTab] ?? '' : '' + const docSyncRef = useRef(null) + + // Add comment state + const [newComment, setNewComment] = useState<{ from: number; to: number; text: string } | null>(null) + const [commentInput, setCommentInput] = useState('') + const [submittingComment, setSubmittingComment] = useState(false) + + // Get docId for current file + const getDocIdForFile = useCallback(() => { + if (!activeTab) return null + return pathDocMap[activeTab] || null + }, [activeTab, pathDocMap]) + + // Set up the add-comment callback + useEffect(() => { + setAddCommentCallback((from, to, text) => { + setNewComment({ from, to, text }) + setCommentInput('') + }) + return () => setAddCommentCallback(null) + }, []) + + const handleSubmitComment = useCallback(async () => { + if (!newComment || !commentInput.trim() || !overleafProjectId) return + const docId = getDocIdForFile() + if (!docId) return + setSubmittingComment(true) + const result = await window.api.overleafAddComment( + overleafProjectId, docId, newComment.from, newComment.text, commentInput.trim() + ) + setSubmittingComment(false) + if (result.success) { + setNewComment(null) + setCommentInput('') + } + }, [newComment, commentInput, overleafProjectId, getDocIdForFile]) - // Handle goTo when file is already open (no editor recreation needed) + // Handle goTo when file is already open useEffect(() => { if (!pendingGoTo || !viewRef.current) return if (activeTab !== pendingGoTo.file) return const view = viewRef.current - const lineNum = Math.min(pendingGoTo.line, view.state.doc.lines) - const lineInfo = view.state.doc.line(lineNum) - view.dispatch({ - selection: { anchor: lineInfo.from }, - effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) - }) + if (pendingGoTo.pos !== undefined) { + const docLen = view.state.doc.length + const from = Math.min(pendingGoTo.pos, docLen) + const to = pendingGoTo.highlight + ? Math.min(from + pendingGoTo.highlight.length, docLen) + : from + view.dispatch({ + selection: { anchor: from, head: to }, + effects: EditorView.scrollIntoView(from, { y: 'center' }) + }) + } else if (pendingGoTo.line) { + const lineNum = Math.min(pendingGoTo.line, view.state.doc.lines) + const lineInfo = view.state.doc.line(lineNum) + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) + }) + } view.focus() useAppStore.getState().setPendingGoTo(null) }, [pendingGoTo]) @@ -83,12 +137,48 @@ export default function Editor() { const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged && activeTab) { + const isRemote = update.transactions.some(tr => tr.annotation(remoteUpdateAnnotation)) const newContent = update.state.doc.toString() setFileContent(activeTab, newContent) - markModified(activeTab, true) + if (!isRemote) { + markModified(activeTab, true) + } + // Notify bridge of content change (both local and remote) for disk sync + const docId = pathDocMap[activeTab] + if (docId) { + window.api.syncContentChanged(docId, newContent) + } + } + if (update.selectionSet) { + const ranges = update.state.field(commentRangesField) + const cursorPos = update.state.selection.main.head + let found: string | null = null + for (const r of ranges) { + if (cursorPos >= r.from && cursorPos <= r.to) { + found = r.threadId + break + } + } + const store = useAppStore.getState() + if (found !== store.focusedThreadId) { + store.setFocusedThreadId(found) + } } }) + // Set up OT sync + let otExt: any[] = [] + if (activeTab) { + const docId = pathDocMap[activeTab] + const version = docId ? docVersions[docId] : undefined + if (docId && version !== undefined) { + const docSync = new OverleafDocSync(docId, version) + docSyncRef.current = docSync + activeDocSyncs.set(docId, docSync) + otExt = [otSyncExtension(docSync)] + } + } + const state = EditorState.create({ doc: content, extensions: [ @@ -113,45 +203,86 @@ export default function Editor() { ]), cosmicLatteTheme, updateListener, - EditorView.lineWrapping + EditorView.lineWrapping, + commentHighlights(), + overleafProjectId ? addCommentTooltip() : [], + ...otExt, ] }) - const view = new EditorView({ - state, - parent: editorRef.current - }) + const view = new EditorView({ state, parent: editorRef.current }) viewRef.current = view - // Apply pending navigation (from log click) + if (docSyncRef.current) { + docSyncRef.current.setView(view) + } + + // Apply pending navigation const goTo = useAppStore.getState().pendingGoTo - if (goTo && goTo.file === activeTab && goTo.line) { + if (goTo && goTo.file === activeTab && (goTo.line || goTo.pos !== undefined)) { requestAnimationFrame(() => { - const lineNum = Math.min(goTo.line, view.state.doc.lines) - const lineInfo = view.state.doc.line(lineNum) - view.dispatch({ - selection: { anchor: lineInfo.from }, - effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) - }) + if (goTo.pos !== undefined) { + const docLen = view.state.doc.length + const from = Math.min(goTo.pos, docLen) + const to = goTo.highlight ? Math.min(from + goTo.highlight.length, docLen) : from + view.dispatch({ + selection: { anchor: from, head: to }, + effects: EditorView.scrollIntoView(from, { y: 'center' }) + }) + } else if (goTo.line) { + const lineNum = Math.min(goTo.line, view.state.doc.lines) + const lineInfo = view.state.doc.line(lineNum) + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) + }) + } view.focus() useAppStore.getState().setPendingGoTo(null) }) } return () => { + if (docSyncRef.current) { + const docId = pathDocMap[activeTab!] + if (docId) activeDocSyncs.delete(docId) + docSyncRef.current.destroy() + docSyncRef.current = null + } viewRef.current?.destroy() viewRef.current = null } - }, [activeTab]) // Re-create when tab changes + }, [activeTab]) + + // Sync comment ranges to CodeMirror + useEffect(() => { + if (!viewRef.current || !activeTab) return + const ranges: CommentRange[] = [] + for (const [threadId, ctx] of Object.entries(commentContexts)) { + if (ctx.file === activeTab && ctx.text) { + ranges.push({ + threadId, + from: ctx.pos, + to: ctx.pos + ctx.text.length, + text: ctx.text, + }) + } + } + viewRef.current.dispatch({ effects: setCommentRangesEffect.of(ranges) }) + }, [commentContexts, activeTab]) + + // Sync hover state + useEffect(() => { + if (!viewRef.current) return + viewRef.current.dispatch({ effects: highlightThreadEffect.of(hoveredThreadId) }) + }, [hoveredThreadId]) if (!activeTab) { return (

Open a file to start editing

-

- Cmd+S Save · Cmd+B Compile · Cmd+` Terminal -

+

Cmd+B Compile · Cmd+` Terminal

) @@ -183,6 +314,37 @@ export default function Editor() { ))}
+ {newComment && ( +
+
+
+ “{newComment.text.length > 60 ? newComment.text.slice(0, 60) + '...' : newComment.text}” +
+