summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-12 17:52:53 -0500
committerhaoyuren <13851610112@163.com>2026-03-12 17:52:53 -0500
commitb116335f9dbde4f483c0b2b8e7bfca5d321c5dfc (patch)
tree8bd84b0f4a54eb879c8cc5a158002e999b23d57e
parentebec1a1073f9cc5b69e125d5b284669545ea3d9f (diff)
Add bidirectional file sync, OT system, comments, and real-time collaboration
Implement full Overleaf integration with Socket.IO v0.9 real-time sync: - FileSyncBridge for bidirectional temp dir ↔ Overleaf sync via chokidar + diff-match-patch - OT state machine, transform functions, and CM6 adapter for collaborative editing - Comment system with highlights, tooltips, and review panel - Project list, file tree management, and socket-based compilation - 3-layer loop prevention (write guards, content equality, debounce) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--electron.vite.config.ts2
-rw-r--r--package-lock.json101
-rw-r--r--package.json7
-rw-r--r--src/main/compilationManager.ts162
-rw-r--r--src/main/fileSyncBridge.ts364
-rw-r--r--src/main/index.ts1219
-rw-r--r--src/main/otClient.ts131
-rw-r--r--src/main/otTransform.ts117
-rw-r--r--src/main/otTypes.ts31
-rw-r--r--src/main/overleafProtocol.ts95
-rw-r--r--src/main/overleafSocket.ts401
-rw-r--r--src/preload/index.ts146
-rw-r--r--src/renderer/src/App.css681
-rw-r--r--src/renderer/src/App.tsx368
-rw-r--r--src/renderer/src/components/Editor.tsx264
-rw-r--r--src/renderer/src/components/FileTree.tsx371
-rw-r--r--src/renderer/src/components/OverleafConnect.tsx171
-rw-r--r--src/renderer/src/components/PdfViewer.tsx34
-rw-r--r--src/renderer/src/components/ProjectList.tsx284
-rw-r--r--src/renderer/src/components/ReviewPanel.tsx309
-rw-r--r--src/renderer/src/components/StatusBar.tsx20
-rw-r--r--src/renderer/src/components/Terminal.tsx7
-rw-r--r--src/renderer/src/components/Toolbar.tsx64
-rw-r--r--src/renderer/src/extensions/addCommentTooltip.ts97
-rw-r--r--src/renderer/src/extensions/commentHighlights.ts227
-rw-r--r--src/renderer/src/extensions/otSyncExtension.ts39
-rw-r--r--src/renderer/src/ot/cmAdapter.ts70
-rw-r--r--src/renderer/src/ot/otClient.ts135
-rw-r--r--src/renderer/src/ot/overleafSync.ts147
-rw-r--r--src/renderer/src/ot/transform.ts174
-rw-r--r--src/renderer/src/ot/types.ts53
-rw-r--r--src/renderer/src/stores/appStore.ts136
32 files changed, 5367 insertions, 1060 deletions
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<string, string>() // docPath → content
+ private fileRefCache = new Map<string, boolean>() // 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<string, string>() // relPath → content
+ private writesInProgress = new Set<string>() // relPaths being written by bridge
+ private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
+ private otClients = new Map<string, OtClient>() // docId → OtClient (non-editor docs)
+ private editorDocs = new Set<string>() // docIds owned by renderer
+ private watcher: chokidar.FSWatcher | null = null
+
+ private socket: OverleafSocket
+ private tmpDir: string
+ private docPathMap: Record<string, string> // docId → relPath
+ private pathDocMap: Record<string, string> // relPath → docId
+ private mainWindow: BrowserWindow
+
+ private serverEventHandler: ((name: string, args: unknown[]) => void) | null = null
+ private stopped = false
+
+ constructor(
+ socket: OverleafSocket,
+ tmpDir: string,
+ docPathMap: Record<string, string>,
+ pathDocMap: Record<string, string>,
+ mainWindow: BrowserWindow
+ ) {
+ this.socket = socket
+ this.tmpDir = tmpDir
+ this.docPathMap = docPathMap
+ this.pathDocMap = pathDocMap
+ this.mainWindow = mainWindow
+ }
+
+ async start(): Promise<void> {
+ // Join ALL docs, fetch content, write to disk
+ await mkdir(this.tmpDir, { recursive: true })
+
+ const docIds = Object.keys(this.docPathMap)
+ for (const docId of docIds) {
+ const relPath = this.docPathMap[docId]
+ try {
+ const result = await this.socket.joinDoc(docId)
+ const content = (result.docLines || []).join('\n')
+ this.lastKnownContent.set(relPath, content)
+
+ // Create OtClient for this doc (bridge owns it initially)
+ const otClient = new OtClient(
+ result.version,
+ (ops, version) => this.sendOps(docId, ops, version),
+ (ops) => this.onRemoteApply(docId, ops)
+ )
+ this.otClients.set(docId, otClient)
+
+ // Write to disk
+ await this.writeToDisk(relPath, content)
+ } catch (e) {
+ console.log(`[FileSyncBridge] failed to join doc ${relPath}:`, e)
+ }
+ }
+
+ // Listen for server events (remote ops on non-editor docs)
+ this.serverEventHandler = (name: string, args: unknown[]) => {
+ if (name === 'otUpdateApplied') {
+ const update = args[0] as { doc?: string; op?: OtOp[]; v?: number } | undefined
+ if (!update?.doc) return
+ const docId = update.doc
+
+ // For non-editor docs, process remote ops through bridge's OtClient
+ if (!this.editorDocs.has(docId) && update.op && update.v !== undefined) {
+ const otClient = this.otClients.get(docId)
+ if (otClient) {
+ otClient.onRemoteOps(update.op, update.v)
+ }
+ }
+
+ // For non-editor docs, handle ack (op with no ops array = ack for our own op)
+ if (!this.editorDocs.has(docId) && !update.op) {
+ const otClient = this.otClients.get(docId)
+ if (otClient) {
+ otClient.onAck()
+ }
+ }
+ }
+ }
+ this.socket.on('serverEvent', this.serverEventHandler)
+
+ // Start watching the temp dir
+ this.watcher = chokidar.watch(this.tmpDir, {
+ ignoreInitial: true,
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
+ ignored: [
+ /(^|[/\\])\../, // dotfiles
+ /\.(aux|log|pdf|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb)$/ // LaTeX output files
+ ]
+ })
+
+ this.watcher.on('change', (absPath: string) => {
+ const relPath = absPath.replace(this.tmpDir + '/', '')
+ this.onFileChanged(relPath)
+ })
+
+ this.watcher.on('add', (absPath: string) => {
+ const relPath = absPath.replace(this.tmpDir + '/', '')
+ // Only process if it's a known doc
+ if (this.pathDocMap[relPath]) {
+ this.onFileChanged(relPath)
+ }
+ })
+
+ console.log(`[FileSyncBridge] started, watching ${this.tmpDir}, ${docIds.length} docs synced`)
+ }
+
+ async stop(): Promise<void> {
+ this.stopped = true
+
+ // Clear all debounce timers
+ for (const timer of this.debounceTimers.values()) {
+ clearTimeout(timer)
+ }
+ this.debounceTimers.clear()
+
+ // Remove server event handler
+ if (this.serverEventHandler) {
+ this.socket.removeListener('serverEvent', this.serverEventHandler)
+ this.serverEventHandler = null
+ }
+
+ // Close watcher
+ if (this.watcher) {
+ await this.watcher.close()
+ this.watcher = null
+ }
+
+ this.otClients.clear()
+ this.lastKnownContent.clear()
+ this.writesInProgress.clear()
+ this.editorDocs.clear()
+
+ console.log('[FileSyncBridge] stopped')
+ }
+
+ // ── Disk change handler ──────────────────────────────────────
+
+ private onFileChanged(relPath: string): void {
+ if (this.stopped) return
+
+ // Layer 1: Skip if bridge is currently writing this file
+ if (this.writesInProgress.has(relPath)) return
+
+ // Layer 3: Debounce 300ms per file
+ const existing = this.debounceTimers.get(relPath)
+ if (existing) clearTimeout(existing)
+
+ this.debounceTimers.set(relPath, setTimeout(() => {
+ this.debounceTimers.delete(relPath)
+ this.processChange(relPath)
+ }, 300))
+ }
+
+ private async processChange(relPath: string): Promise<void> {
+ if (this.stopped) return
+
+ const docId = this.pathDocMap[relPath]
+ if (!docId) return
+
+ let newContent: string
+ try {
+ newContent = await readFile(join(this.tmpDir, relPath), 'utf-8')
+ } catch {
+ return // file deleted or unreadable
+ }
+
+ const lastKnown = this.lastKnownContent.get(relPath)
+
+ // Layer 2: Content equality check
+ if (newContent === lastKnown) return
+
+ console.log(`[FileSyncBridge] disk change detected: ${relPath} (${(newContent.length)} chars)`)
+
+ if (this.editorDocs.has(docId)) {
+ // Doc is open in editor → send to renderer via IPC
+ this.lastKnownContent.set(relPath, newContent)
+ this.mainWindow.webContents.send('sync:externalEdit', { docId, content: newContent })
+ } else {
+ // Doc NOT open in editor → bridge handles OT directly
+ const oldContent = lastKnown ?? ''
+ this.lastKnownContent.set(relPath, newContent)
+
+ const diffs = dmp.diff_main(oldContent, newContent)
+ dmp.diff_cleanupEfficiency(diffs)
+ const ops = diffsToOtOps(diffs)
+
+ if (ops.length > 0) {
+ const otClient = this.otClients.get(docId)
+ if (otClient) {
+ otClient.onLocalOps(ops)
+ }
+ }
+ }
+ }
+
+ // ── Send OT ops to Overleaf (for non-editor docs) ───────────
+
+ private sendOps(docId: string, ops: OtOp[], version: number): void {
+ const relPath = this.docPathMap[docId]
+ const content = relPath ? this.lastKnownContent.get(relPath) ?? '' : ''
+ const hash = createHash('sha1').update(content).digest('hex')
+ this.socket.applyOtUpdate(docId, ops, version, hash)
+ }
+
+ // ── Apply remote ops (for non-editor docs) ──────────────────
+
+ private onRemoteApply(docId: string, ops: OtOp[]): void {
+ const relPath = this.docPathMap[docId]
+ if (!relPath) return
+
+ const currentContent = this.lastKnownContent.get(relPath) ?? ''
+ const newContent = applyOpsToText(currentContent, ops)
+ this.lastKnownContent.set(relPath, newContent)
+ this.writeToDisk(relPath, newContent)
+ }
+
+ // ── Called by main process when editor/remote changes content ─
+
+ /** Called when renderer notifies bridge that editor content changed */
+ onEditorContentChanged(docId: string, content: string): void {
+ const relPath = this.docPathMap[docId]
+ if (!relPath) return
+
+ // Update last known content
+ this.lastKnownContent.set(relPath, content)
+
+ // Write to disk so external tools can see the change
+ this.writeToDisk(relPath, content)
+ }
+
+ // ── Editor doc tracking ──────────────────────────────────────
+
+ /** Renderer opened this doc in the editor — bridge stops owning OT */
+ addEditorDoc(docId: string): void {
+ this.editorDocs.add(docId)
+ // Bridge's OtClient for this doc is no longer used (renderer has its own)
+ // But we keep the doc joined in the socket
+ }
+
+ /** Renderer closed this doc from the editor — bridge takes over OT */
+ removeEditorDoc(docId: string): void {
+ this.editorDocs.delete(docId)
+
+ // Re-join the doc to get fresh version, since renderer's OtClient was tracking it
+ const relPath = this.docPathMap[docId]
+ if (!relPath) return
+
+ this.socket.joinDoc(docId).then((result) => {
+ const content = (result.docLines || []).join('\n')
+ this.lastKnownContent.set(relPath, content)
+
+ // Create fresh OtClient with current version
+ const otClient = new OtClient(
+ result.version,
+ (ops, version) => this.sendOps(docId, ops, version),
+ (ops) => this.onRemoteApply(docId, ops)
+ )
+ this.otClients.set(docId, otClient)
+
+ // Write latest content to disk
+ this.writeToDisk(relPath, content)
+ }).catch((e) => {
+ console.log(`[FileSyncBridge] failed to re-join doc ${relPath}:`, e)
+ })
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────
+
+ private async writeToDisk(relPath: string, content: string): Promise<void> {
+ const fullPath = join(this.tmpDir, relPath)
+ const dir = fullPath.substring(0, fullPath.lastIndexOf('/'))
+
+ // Set write guard
+ this.writesInProgress.add(relPath)
+
+ try {
+ await mkdir(dir, { recursive: true })
+ await writeFile(fullPath, content, 'utf-8')
+ } catch (e) {
+ console.log(`[FileSyncBridge] write error for ${relPath}:`, e)
+ }
+
+ // Clear write guard after 150ms (chokidar needs time to fire & be ignored)
+ setTimeout(() => {
+ this.writesInProgress.delete(relPath)
+ }, 150)
+ }
+
+ /** Get the temp dir path */
+ get dir(): string {
+ return this.tmpDir
+ }
+
+ /** Get content for a doc (used by compilation manager) */
+ getDocContent(relPath: string): string | undefined {
+ return this.lastKnownContent.get(relPath)
+ }
+
+ /** Check if a doc's content is known */
+ hasDoc(relPath: string): boolean {
+ return this.lastKnownContent.has(relPath)
+ }
+}
+
+// ── Utility functions ────────────────────────────────────────
+
+/** Convert diff-match-patch diffs to OT ops */
+function diffsToOtOps(diffs: [number, string][]): OtOp[] {
+ const ops: OtOp[] = []
+ let pos = 0
+
+ for (const [type, text] of diffs) {
+ switch (type) {
+ case 0: // DIFF_EQUAL
+ pos += text.length
+ break
+ case 1: // DIFF_INSERT
+ ops.push({ i: text, p: pos })
+ pos += text.length
+ break
+ case -1: // DIFF_DELETE
+ ops.push({ d: text, p: pos })
+ // Don't advance pos — deletion doesn't move cursor forward
+ break
+ }
+ }
+
+ return ops
+}
+
+/** Apply OT ops to a text string */
+function applyOpsToText(text: string, ops: OtOp[]): string {
+ // Sort ops by position descending so we can apply without position shifting
+ const sortedOps = [...ops].sort((a, b) => b.p - a.p)
+
+ for (const op of sortedOps) {
+ if (isInsert(op)) {
+ text = text.slice(0, op.p) + op.i + text.slice(op.p)
+ } else if (isDelete(op)) {
+ text = text.slice(0, op.p) + text.slice(op.p + op.d.length)
+ }
+ // Comment ops don't modify text
+ }
+
+ return text
+}
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<typeof watch> | 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<FileNode[]> {
- 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<string | null> {
- 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<string>()
- // 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<string[]> {
- const packages = new Set<string>()
- for (const name of names) {
- const result = await new Promise<string>((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<void> {
+ try {
+ await writeFile(cookiePath, JSON.stringify({ cookie: overleafSessionCookie, csrf: overleafCsrfToken }))
+ } catch { /* ignore */ }
+}
+
+let sessionLoadPromise: Promise<void> | 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<void> {
+ 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<boolean> => {
+ 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<boolean>((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<void>((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<boolean> {
- 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<string, string>
+ pathDocMap: Record<string, string>
+ fileRefs: Array<{ id: string; path: string }>
+ rootFolderId: string
+} {
+ const docPathMap: Record<string, string> = {}
+ const pathDocMap: Record<string, string> = {}
+ 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<string, (name: string, args: unknown[]) => 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<void>((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<string, { file: string; text: string; pos: number }> = {}
+
+ 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<number, (data: unknown) => void>()
+ private eventWaiters = new Map<string, (args: unknown[]) => void>()
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
+ private reconnectAttempt = 0
+ private maxReconnectDelay = 30000
+ private joinedDocs = new Set<string>()
+ 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<JoinProjectResult> {
+ this.projectId = projectId
+ this.cookie = cookie
+ this.shouldReconnect = true
+ return this.doConnect()
+ }
+
+ private async doConnect(): Promise<JoinProjectResult> {
+ 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<typeof setTimeout> | null = null
+
+ private handleMessage(
+ raw: string,
+ connectResolve?: (result: JoinProjectResult) => void,
+ connectReject?: (err: Error) => void,
+ connectTimeout?: ReturnType<typeof setTimeout>
+ ) {
+ 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<typeof setTimeout>
+ ) {
+ // 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<JoinDocResult> {
+ 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<void> {
+ await this.emitWithAck('leaveDoc', [docId])
+ this.joinedDocs.delete(docId)
+ }
+
+ async applyOtUpdate(docId: string, ops: unknown[], version: number, hash: string): Promise<void> {
+ // 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<unknown> {
+ 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<unknown[]> {
+ 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<string | null>,
readBinary: (path: string) => ipcRenderer.invoke('fs:readBinary', path) as Promise<ArrayBuffer>,
- 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<string, unknown>; 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<string, string>
+ pathDocMap?: Record<string, string>
+ 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<string, { file: string; text: string; pos: number }>
+ }>,
+ 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<string, OverleafDocSync>()
+
+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 (
+ <div style={{ padding: 40, color: '#c00', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
+ <h2>Render Error</h2>
+ <p>{this.state.error.message}</p>
+ <pre>{this.state.error.stack}</pre>
+ </div>
+ )
+ }
+ 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<string, { file: string; text: string; pos: number }> = {}
+ 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 (
+ <div className="welcome-screen">
+ <div className="welcome-drag-bar" />
+ <div className="welcome-content">
+ <div className="overleaf-spinner" />
+ </div>
+ </div>
+ )
}
- return (
- <>
- <ModalProvider />
- {showOverleaf && (
- <OverleafConnect
- onConnected={(path) => {
- setShowOverleaf(false)
- setProjectPath(path)
- }}
- onCancel={() => setShowOverleaf(false)}
- />
- )}
- {!projectPath ? (
+ // Login screen
+ if (screen === 'login') {
+ return (
+ <>
+ <ModalProvider />
<div className="welcome-screen">
<div className="welcome-drag-bar" />
<div className="welcome-content">
<h1>ClaudeTeX</h1>
- <p>LaTeX editor with AI and Overleaf sync</p>
- <button className="btn btn-primary btn-large" onClick={handleOpenProject}>
- Open Project
- </button>
- <button className="btn btn-secondary btn-large" onClick={() => setShowOverleaf(true)}>
- Clone from Overleaf
+ <p>LaTeX editor with real-time Overleaf sync</p>
+ <button className="btn btn-primary btn-large" onClick={handleLogin}>
+ Sign in to Overleaf
</button>
</div>
</div>
- ) : (
- <div className="app">
- <Toolbar onCompile={handleCompile} onSave={handleSave} onOpenProject={handleOpenProject} />
- <div className="main-content">
- <PanelGroup direction="horizontal">
- {showFileTree && (
- <>
- <Panel defaultSize={18} minSize={12} maxSize={35}>
- <FileTree />
- </Panel>
- <PanelResizeHandle className="resize-handle resize-handle-h" />
- </>
- )}
- <Panel minSize={30}>
- <PanelGroup direction="vertical">
- <Panel defaultSize={showTerminal ? 70 : 100} minSize={30}>
- <PanelGroup direction="horizontal">
- <Panel defaultSize={50} minSize={25}>
- <Editor />
- </Panel>
- <PanelResizeHandle className="resize-handle resize-handle-h" />
- <Panel defaultSize={50} minSize={20}>
- <PdfViewer />
- </Panel>
- </PanelGroup>
- </Panel>
- {showTerminal && (
- <>
- <PanelResizeHandle className="resize-handle resize-handle-v" />
- <Panel defaultSize={30} minSize={15} maxSize={60}>
- <Terminal />
- </Panel>
- </>
- )}
- </PanelGroup>
- </Panel>
- </PanelGroup>
- </div>
- <StatusBar />
+ </>
+ )
+ }
+
+ // Project list screen
+ if (screen === 'projects') {
+ return (
+ <>
+ <ModalProvider />
+ <ProjectList onOpenProject={handleOpenProject} />
+ </>
+ )
+ }
+
+ // Editor screen
+ return (
+ <ErrorBoundary>
+ <ModalProvider />
+ <div className="app">
+ <Toolbar onCompile={handleCompile} onBack={handleBackToProjects} />
+ <div className="main-content">
+ <PanelGroup direction="horizontal">
+ {showFileTree && (
+ <>
+ <Panel defaultSize={18} minSize={12} maxSize={35}>
+ <FileTree />
+ </Panel>
+ <PanelResizeHandle className="resize-handle resize-handle-h" />
+ </>
+ )}
+ <Panel minSize={30}>
+ <PanelGroup direction="vertical">
+ <Panel defaultSize={showTerminal ? 70 : 100} minSize={30}>
+ <PanelGroup direction="horizontal">
+ <Panel defaultSize={50} minSize={25}>
+ <Editor />
+ </Panel>
+ <PanelResizeHandle className="resize-handle resize-handle-h" />
+ <Panel defaultSize={50} minSize={20}>
+ <PdfViewer />
+ </Panel>
+ </PanelGroup>
+ </Panel>
+ {showTerminal && (
+ <>
+ <PanelResizeHandle className="resize-handle resize-handle-v" />
+ <Panel defaultSize={30} minSize={15} maxSize={60}>
+ <Terminal />
+ </Panel>
+ </>
+ )}
+ </PanelGroup>
+ </Panel>
+ </PanelGroup>
+ {showReviewPanel && (
+ <div className="review-sidebar">
+ <ReviewPanel />
+ </div>
+ )}
</div>
- )}
- </>
+ <StatusBar />
+ </div>
+ </ErrorBoundary>
)
}
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<OverleafDocSync | null>(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 (
<div className="editor-empty">
<div className="editor-empty-content">
<p>Open a file to start editing</p>
- <p className="shortcut-hint">
- Cmd+S Save &middot; Cmd+B Compile &middot; Cmd+` Terminal
- </p>
+ <p className="shortcut-hint">Cmd+B Compile &middot; Cmd+` Terminal</p>
</div>
</div>
)
@@ -183,6 +314,37 @@ export default function Editor() {
))}
</div>
<div ref={editorRef} className="editor-content" />
+ {newComment && (
+ <div className="add-comment-overlay">
+ <div className="add-comment-card">
+ <div className="add-comment-quote">
+ &ldquo;{newComment.text.length > 60 ? newComment.text.slice(0, 60) + '...' : newComment.text}&rdquo;
+ </div>
+ <textarea
+ className="add-comment-input"
+ value={commentInput}
+ onChange={(e) => setCommentInput(e.target.value)}
+ placeholder="Write a comment..."
+ autoFocus
+ rows={2}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmitComment() }
+ if (e.key === 'Escape') setNewComment(null)
+ }}
+ />
+ <div className="add-comment-actions">
+ <button className="add-comment-cancel" onClick={() => setNewComment(null)}>Cancel</button>
+ <button
+ className="add-comment-submit"
+ onClick={handleSubmitComment}
+ disabled={!commentInput.trim() || submittingComment}
+ >
+ {submittingComment ? 'Sending...' : 'Comment'}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
</div>
)
}
diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx
index 3163485..e2f31f1 100644
--- a/src/renderer/src/components/FileTree.tsx
+++ b/src/renderer/src/components/FileTree.tsx
@@ -1,19 +1,25 @@
-import { useState, useCallback } from 'react'
-import { useAppStore } from '../stores/appStore'
-import { showInput, showConfirm } from '../hooks/useModal'
-
-interface FileNode {
- name: string
- path: string
- isDir: boolean
- children?: FileNode[]
+import { useState, useCallback, useEffect, useRef } from 'react'
+import { useAppStore, type FileNode } from '../stores/appStore'
+
+interface ContextMenuState {
+ x: number
+ y: number
+ node: FileNode
}
-function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) {
+function FileTreeNode({
+ node,
+ depth,
+ onContextMenu
+}: {
+ node: FileNode
+ depth: number
+ onContextMenu: (e: React.MouseEvent, node: FileNode) => void
+}) {
const [expanded, setExpanded] = useState(depth < 2)
- const { activeTab, mainDocument, openFile, setFileContent, setStatusMessage } = useAppStore()
+ const { activeTab, openFile, setFileContent, setStatusMessage, mainDocument, docPathMap } = useAppStore()
const isActive = activeTab === node.path
- const isMainDoc = mainDocument === node.path
+ const isMainDoc = node.docId && mainDocument === node.docId
const handleClick = useCallback(async () => {
if (node.isDir) {
@@ -21,6 +27,7 @@ function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) {
return
}
+ // Binary files — skip
const ext = node.name.split('.').pop()?.toLowerCase()
if (ext === 'pdf' || ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'svg') {
if (ext === 'pdf') {
@@ -29,12 +36,34 @@ function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) {
return
}
- try {
- const content = await window.api.readFile(node.path)
- setFileContent(node.path, content)
- openFile(node.path, node.name)
- } catch {
- setStatusMessage('Failed to read file')
+ // Join doc via socket
+ if (node.docId) {
+ setStatusMessage('Opening document...')
+ try {
+ const result = await window.api.otJoinDoc(node.docId)
+ if (result.success && result.content !== undefined) {
+ setFileContent(node.path, result.content)
+ openFile(node.path, node.name)
+ if (result.version !== undefined) {
+ useAppStore.getState().setDocVersion(node.docId, result.version)
+ }
+ if (result.ranges?.comments) {
+ const contexts: Record<string, { file: string; text: string; pos: number }> = {}
+ for (const c of result.ranges.comments) {
+ if (c.op?.t) {
+ contexts[c.op.t] = { file: node.path, text: c.op.c || '', pos: c.op.p || 0 }
+ }
+ }
+ const existing = useAppStore.getState().commentContexts
+ useAppStore.getState().setCommentContexts({ ...existing, ...contexts })
+ }
+ setStatusMessage('Ready')
+ } else {
+ setStatusMessage(result.message || 'Failed to open document')
+ }
+ } catch {
+ setStatusMessage('Failed to open document')
+ }
}
}, [node, expanded, openFile, setFileContent, setStatusMessage])
@@ -47,119 +76,263 @@ function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) {
: ext === 'png' || ext === 'jpg' ? '🖼️'
: '📝'
- const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
-
- const handleContextMenu = (e: React.MouseEvent) => {
- e.preventDefault()
- setContextMenu({ x: e.clientX, y: e.clientY })
- const handler = () => { setContextMenu(null); window.removeEventListener('click', handler) }
- window.addEventListener('click', handler)
- }
-
- const handleNewFile = async () => {
- setContextMenu(null)
- const name = await showInput('New File', 'main.tex')
- if (!name) return
- const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/'))
- await window.api.createFile(dir, name)
- }
-
- const handleNewFolder = async () => {
- setContextMenu(null)
- const name = await showInput('New Folder', 'figures')
- if (!name) return
- const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/'))
- await window.api.createDir(dir, name)
- }
-
- const handleRename = async () => {
- setContextMenu(null)
- const newName = await showInput('Rename', node.name, node.name)
- if (!newName || newName === node.name) return
- const dir = node.path.substring(0, node.path.lastIndexOf('/'))
- await window.api.renameFile(node.path, dir + '/' + newName)
- }
-
- const handleDelete = async () => {
- setContextMenu(null)
- const ok = await showConfirm('Delete', `Delete "${node.name}"?`, true)
- if (!ok) return
- await window.api.deleteFile(node.path)
- }
-
- const handleSetMainDoc = () => {
- setContextMenu(null)
- useAppStore.getState().setMainDocument(node.path)
- setStatusMessage(`Main document: ${node.name}`)
- }
-
- const handleReveal = () => {
- window.api.showInFinder(node.path)
- setContextMenu(null)
- }
-
return (
<div>
<div
className={`file-tree-item ${isActive ? 'active' : ''}`}
style={{ paddingLeft: depth * 16 + 8 }}
onClick={handleClick}
- onContextMenu={handleContextMenu}
+ onContextMenu={(e) => onContextMenu(e, node)}
>
<span className="file-icon">{icon}</span>
- <span className="file-name">{node.name}</span>
- {isMainDoc && <span className="main-doc-badge">main</span>}
+ <span className="file-name">
+ {node.name}
+ {isMainDoc && <span className="main-doc-badge">main</span>}
+ </span>
</div>
- {contextMenu && (
- <div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}>
- {!node.isDir && ext === 'tex' && (
- <>
- <div className="context-menu-item" onClick={handleSetMainDoc}>
- {isMainDoc ? '✓ Main Document' : 'Set as Main Document'}
- </div>
- <div className="context-menu-separator" />
- </>
- )}
- <div className="context-menu-item" onClick={handleNewFile}>New File</div>
- <div className="context-menu-item" onClick={handleNewFolder}>New Folder</div>
- <div className="context-menu-separator" />
- <div className="context-menu-item" onClick={handleRename}>Rename</div>
- <div className="context-menu-item danger" onClick={handleDelete}>Delete</div>
- <div className="context-menu-separator" />
- <div className="context-menu-item" onClick={handleReveal}>Reveal in Finder</div>
- </div>
- )}
{node.isDir && expanded && node.children?.map((child) => (
- <FileTreeNode key={child.path} node={child} depth={depth + 1} />
+ <FileTreeNode key={child.path} node={child} depth={depth + 1} onContextMenu={onContextMenu} />
))}
</div>
)
}
export default function FileTree() {
- const { files, projectPath } = useAppStore()
+ const { files } = useAppStore()
+ const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null)
+ const menuRef = useRef<HTMLDivElement>(null)
+
+ // Close context menu on outside click or escape
+ useEffect(() => {
+ if (!ctxMenu) return
+ const handleClick = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setCtxMenu(null)
+ }
+ }
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setCtxMenu(null)
+ }
+ document.addEventListener('mousedown', handleClick)
+ document.addEventListener('keydown', handleKey)
+ return () => {
+ document.removeEventListener('mousedown', handleClick)
+ document.removeEventListener('keydown', handleKey)
+ }
+ }, [ctxMenu])
+
+ const handleContextMenu = useCallback((e: React.MouseEvent, node: FileNode) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setCtxMenu({ x: e.clientX, y: e.clientY, node })
+ }, [])
+
+ const closeMenu = () => setCtxMenu(null)
+
+ const handleSetMainDoc = () => {
+ if (!ctxMenu) return
+ const node = ctxMenu.node
+ if (node.docId) {
+ useAppStore.getState().setMainDocument(node.docId)
+ useAppStore.getState().setStatusMessage(`Main document set to ${node.name}`)
+ }
+ closeMenu()
+ }
+
+ const handleCopyPath = () => {
+ if (!ctxMenu) return
+ navigator.clipboard.writeText(ctxMenu.node.path)
+ useAppStore.getState().setStatusMessage('Path copied')
+ closeMenu()
+ }
+
+ const handleRename = async () => {
+ if (!ctxMenu) return
+ const node = ctxMenu.node
+ const projectId = useAppStore.getState().overleafProjectId
+ if (!projectId) { closeMenu(); return }
+
+ const newName = prompt('New name:', node.name)
+ if (!newName?.trim() || newName === node.name) { closeMenu(); return }
+
+ let entityType: string
+ let entityId: string
+ if (node.isDir && node.folderId) {
+ entityType = 'folder'
+ entityId = node.folderId
+ } else if (node.docId) {
+ entityType = 'doc'
+ entityId = node.docId
+ } else if (node.fileRefId) {
+ entityType = 'file'
+ entityId = node.fileRefId
+ } else {
+ closeMenu(); return
+ }
+
+ const result = await window.api.overleafRenameEntity(projectId, entityType, entityId, newName.trim())
+ if (result.success) {
+ useAppStore.getState().setStatusMessage(`Renamed to ${newName.trim()}`)
+ // Reconnect to refresh file tree
+ await reconnectProject(projectId)
+ } else {
+ useAppStore.getState().setStatusMessage(`Rename failed: ${result.message}`)
+ }
+ closeMenu()
+ }
+
+ const handleDelete = async () => {
+ if (!ctxMenu) return
+ const node = ctxMenu.node
+ const projectId = useAppStore.getState().overleafProjectId
+ if (!projectId) { closeMenu(); return }
+
+ if (!confirm(`Delete "${node.name}"?`)) { closeMenu(); return }
+
+ let entityType: string
+ let entityId: string
+ if (node.isDir && node.folderId) {
+ entityType = 'folder'
+ entityId = node.folderId
+ } else if (node.docId) {
+ entityType = 'doc'
+ entityId = node.docId
+ } else if (node.fileRefId) {
+ entityType = 'file'
+ entityId = node.fileRefId
+ } else {
+ closeMenu(); return
+ }
+
+ const result = await window.api.overleafDeleteEntity(projectId, entityType, entityId)
+ if (result.success) {
+ useAppStore.getState().setStatusMessage(`Deleted ${node.name}`)
+ await reconnectProject(projectId)
+ } else {
+ useAppStore.getState().setStatusMessage(`Delete failed: ${result.message}`)
+ }
+ closeMenu()
+ }
const handleNewFile = async () => {
- if (!projectPath) return
- const name = await showInput('New File', 'main.tex')
- if (!name) return
- await window.api.createFile(projectPath, name)
+ if (!ctxMenu) return
+ const node = ctxMenu.node
+ const projectId = useAppStore.getState().overleafProjectId
+ if (!projectId) { closeMenu(); return }
+
+ const name = prompt('New file name:', 'untitled.tex')
+ if (!name?.trim()) { closeMenu(); return }
+
+ const parentId = node.isDir && node.folderId
+ ? node.folderId
+ : useAppStore.getState().rootFolderId
+
+ const result = await window.api.overleafCreateDoc(projectId, parentId, name.trim())
+ if (result.success) {
+ useAppStore.getState().setStatusMessage(`Created ${name.trim()}`)
+ await reconnectProject(projectId)
+ } else {
+ useAppStore.getState().setStatusMessage(`Create failed: ${result.message}`)
+ }
+ closeMenu()
+ }
+
+ const handleNewFolder = async () => {
+ if (!ctxMenu) return
+ const node = ctxMenu.node
+ const projectId = useAppStore.getState().overleafProjectId
+ if (!projectId) { closeMenu(); return }
+
+ const name = prompt('New folder name:', 'new-folder')
+ if (!name?.trim()) { closeMenu(); return }
+
+ const parentId = node.isDir && node.folderId
+ ? node.folderId
+ : useAppStore.getState().rootFolderId
+
+ const result = await window.api.overleafCreateFolder(projectId, parentId, name.trim())
+ if (result.success) {
+ useAppStore.getState().setStatusMessage(`Created folder ${name.trim()}`)
+ await reconnectProject(projectId)
+ } else {
+ useAppStore.getState().setStatusMessage(`Create failed: ${result.message}`)
+ }
+ closeMenu()
+ }
+
+ const handleOpenInOverleaf = () => {
+ const projectId = useAppStore.getState().overleafProjectId
+ if (projectId) {
+ window.api.openExternal(`https://www.overleaf.com/project/${projectId}`)
+ }
+ closeMenu()
}
return (
<div className="file-tree">
<div className="file-tree-header">
<span>FILES</span>
- <button className="file-tree-action" onClick={handleNewFile} title="New file">+</button>
</div>
<div className="file-tree-content">
{files.map((node) => (
- <FileTreeNode key={node.path} node={node} depth={0} />
+ <FileTreeNode key={node.path} node={node} depth={0} onContextMenu={handleContextMenu} />
))}
{files.length === 0 && (
<div className="file-tree-empty">No files found</div>
)}
</div>
+
+ {ctxMenu && (
+ <div
+ ref={menuRef}
+ className="context-menu"
+ style={{ left: ctxMenu.x, top: ctxMenu.y }}
+ >
+ {ctxMenu.node.docId && ctxMenu.node.name.endsWith('.tex') && (
+ <div className="context-menu-item" onClick={handleSetMainDoc}>
+ Set as Main Document
+ </div>
+ )}
+ <div className="context-menu-item" onClick={handleCopyPath}>
+ Copy Path
+ </div>
+ <div className="context-menu-separator" />
+ <div className="context-menu-item" onClick={handleRename}>
+ Rename
+ </div>
+ {ctxMenu.node.isDir && (
+ <>
+ <div className="context-menu-item" onClick={handleNewFile}>
+ New File
+ </div>
+ <div className="context-menu-item" onClick={handleNewFolder}>
+ New Folder
+ </div>
+ </>
+ )}
+ <div className="context-menu-separator" />
+ <div className="context-menu-item danger" onClick={handleDelete}>
+ Delete
+ </div>
+ <div className="context-menu-separator" />
+ <div className="context-menu-item" onClick={handleOpenInOverleaf}>
+ Open in Overleaf
+ </div>
+ </div>
+ )}
</div>
)
}
+
+/** Reconnect to refresh the file tree after a file operation */
+async function reconnectProject(projectId: string) {
+ const result = await window.api.otConnect(projectId)
+ if (result.success) {
+ const store = useAppStore.getState()
+ if (result.files) store.setFiles(result.files as any)
+ if (result.project) store.setOverleafProject(result.project)
+ if (result.docPathMap && result.pathDocMap) store.setDocMaps(result.docPathMap, result.pathDocMap)
+ if (result.fileRefs) store.setFileRefs(result.fileRefs)
+ if (result.rootFolderId) store.setRootFolderId(result.rootFolderId)
+ }
+}
diff --git a/src/renderer/src/components/OverleafConnect.tsx b/src/renderer/src/components/OverleafConnect.tsx
deleted file mode 100644
index 6258643..0000000
--- a/src/renderer/src/components/OverleafConnect.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import { useState, useEffect } from 'react'
-import { useAppStore } from '../stores/appStore'
-
-interface Props {
- onConnected: (projectPath: string) => void
- onCancel: () => void
-}
-
-export default function OverleafConnect({ onConnected, onCancel }: Props) {
- const [projectUrl, setProjectUrl] = useState('')
- const [token, setToken] = useState('')
- const [hasStoredToken, setHasStoredToken] = useState(false)
- const [busy, setBusy] = useState(false)
- const [busyText, setBusyText] = useState('')
- const [rememberMe, setRememberMe] = useState(true)
- const [error, setError] = useState('')
- const { setStatusMessage } = useAppStore()
-
- // Check if we already have stored credentials
- useEffect(() => {
- window.api.overleafCheck().then(({ loggedIn }) => {
- if (loggedIn) setHasStoredToken(true)
- })
- }, [])
-
- const extractProjectId = (url: string): string | null => {
- const cleaned = url.trim()
- if (!cleaned) return null
- const patterns = [
- /overleaf\.com\/project\/([a-zA-Z0-9]+)/,
- /overleaf\.com\/read\/([a-zA-Z0-9]+)/,
- /git\.overleaf\.com\/([a-zA-Z0-9]+)/,
- /^([a-zA-Z0-9]{10,})$/,
- ]
- for (const p of patterns) {
- const m = cleaned.match(p)
- if (m) return m[1]
- }
- return null
- }
-
- const projectId = extractProjectId(projectUrl)
-
- const handleClone = async () => {
- if (!projectUrl.trim()) {
- setError('Please paste an Overleaf project URL'); return
- }
- if (!projectId) {
- setError('Could not find project ID in this URL.\nExpected: https://www.overleaf.com/project/abc123...'); return
- }
- if (!token.trim()) {
- setError('Please enter your Git Authentication Token'); return
- }
-
- setError('')
- setBusy(true)
- setBusyText('Choose where to save...')
- setStatusMessage('Connecting to Overleaf...')
-
- const parentDir = await window.api.selectSaveDir()
- if (!parentDir) {
- setBusy(false)
- return
- }
- const dest = parentDir + '/overleaf-' + projectId
-
- setBusyText('Verifying token & cloning...')
-
- const result = await window.api.overleafCloneWithAuth(projectId, dest, token.trim(), rememberMe)
-
- setBusy(false)
-
- if (result.success) {
- setStatusMessage('Cloned successfully')
- onConnected(dest)
- } else {
- setStatusMessage('Clone failed')
- setError(result.detail || 'Unknown error')
- }
- }
-
- const handleClearToken = async () => {
- await window.api.overleafLogout()
- setHasStoredToken(false)
- setToken('')
- }
-
- return (
- <div className="modal-overlay" onClick={onCancel}>
- <div className="overleaf-dialog" onClick={(e) => e.stopPropagation()}>
- <div className="overleaf-header">
- <h2>Clone from Overleaf</h2>
- <button className="overleaf-close" onClick={onCancel}>x</button>
- </div>
-
- {error && <div className="overleaf-error">{error}</div>}
-
- {busy ? (
- <div className="overleaf-body">
- <div className="overleaf-cloning">
- <div className="overleaf-spinner" />
- <div className="overleaf-log">{busyText}</div>
- </div>
- </div>
- ) : (
- <div className="overleaf-body">
- {/* Project URL */}
- <label className="overleaf-label">Project URL</label>
- <input
- className="modal-input"
- type="text"
- value={projectUrl}
- onChange={(e) => { setProjectUrl(e.target.value); setError('') }}
- placeholder="https://www.overleaf.com/project/..."
- autoFocus
- />
- <div className="overleaf-help">
- Copy from your browser address bar, or from Overleaf Menu &rarr; Sync &rarr; Git.
- </div>
- {projectId && (
- <div className="overleaf-id-preview">
- Project ID: <code>{projectId}</code>
- </div>
- )}
-
- {/* Token */}
- <div className="overleaf-section-title" style={{ marginTop: 20 }}>
- Git Authentication Token
- {hasStoredToken && (
- <span className="overleaf-saved-hint">
- (saved in Keychain — <button className="overleaf-link-btn" onClick={handleClearToken}>clear</button>)
- </span>
- )}
- </div>
- <input
- className="modal-input"
- type="password"
- value={token}
- onChange={(e) => setToken(e.target.value)}
- placeholder="olp_..."
- onKeyDown={(e) => { if (e.key === 'Enter') handleClone() }}
- />
- <label className="overleaf-checkbox">
- <input
- type="checkbox"
- checked={rememberMe}
- onChange={(e) => setRememberMe(e.target.checked)}
- />
- Remember token (saved in macOS Keychain)
- </label>
-
- <div className="overleaf-help">
- Generate at{' '}
- <span className="overleaf-link" onClick={() => window.api.openExternal('https://www.overleaf.com/user/settings')}>
- Overleaf Account Settings
- </span>
- {' '}&rarr; Git Integration. Requires premium.
- </div>
-
- <div className="modal-actions">
- <button className="btn btn-secondary" onClick={onCancel}>Cancel</button>
- <button className="btn btn-primary" onClick={handleClone}>
- Verify & Clone
- </button>
- </div>
- </div>
- )}
- </div>
- </div>
- )
-}
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
index e702f15..ea2820a 100644
--- a/src/renderer/src/components/PdfViewer.tsx
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -147,32 +147,26 @@ export default function PdfViewer() {
: logEntries.filter((e) => e.level === logFilter)
// Navigate to file:line in editor
- const handleEntryClick = async (entry: LogEntry) => {
+ const handleEntryClick = (entry: LogEntry) => {
if (!entry.line) return
- const { projectPath, mainDocument } = useAppStore.getState()
- if (!projectPath) return
+ const store = useAppStore.getState()
- // If no file specified, use the main document
- const entryFile = entry.file || (mainDocument ? mainDocument.split('/').pop()! : null)
+ // If no file specified, try to use the main document's path
+ const entryFile = entry.file || null
if (!entryFile) return
- // Try resolving the file path
- const candidates = [
- entryFile.startsWith('/') ? entryFile : `${projectPath}/${entryFile}`,
- ]
- if (mainDocument) {
- const dir = mainDocument.substring(0, mainDocument.lastIndexOf('/'))
- candidates.push(`${dir}/${entryFile}`)
- }
+ // In socket mode, files are keyed by relative path in fileContents
+ // Try to find a matching open file
+ const candidates = [entryFile]
+ // Also try without leading ./ or path prefix
+ if (entryFile.startsWith('./')) candidates.push(entryFile.slice(2))
- for (const fullPath of candidates) {
- try {
- const content = await window.api.readFile(fullPath)
- useAppStore.getState().setFileContent(fullPath, content)
- useAppStore.getState().openFile(fullPath, fullPath.split('/').pop() || entryFile)
- useAppStore.getState().setPendingGoTo({ file: fullPath, line: entry.line! })
+ for (const path of candidates) {
+ if (store.fileContents[path]) {
+ store.openFile(path, path.split('/').pop() || path)
+ store.setPendingGoTo({ file: path, line: entry.line! })
return
- } catch { /* try next */ }
+ }
}
}
diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx
new file mode 100644
index 0000000..170e3ea
--- /dev/null
+++ b/src/renderer/src/components/ProjectList.tsx
@@ -0,0 +1,284 @@
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useAppStore } from '../stores/appStore'
+
+interface OverleafProject {
+ id: string
+ name: string
+ lastUpdated: string
+ owner?: { firstName: string; lastName: string; email?: string }
+ lastUpdatedBy?: { firstName: string; lastName: string } | null
+ accessLevel?: string
+ source?: string
+}
+
+type SortKey = 'lastUpdated' | 'name' | 'owner'
+type SortOrder = 'asc' | 'desc'
+
+interface Props {
+ onOpenProject: (projectId: string) => void
+}
+
+export default function ProjectList({ onOpenProject }: Props) {
+ const [projects, setProjects] = useState<OverleafProject[]>([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [searchFilter, setSearchFilter] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [busyText, setBusyText] = useState('')
+ const [sortBy, setSortBy] = useState<SortKey>('lastUpdated')
+ const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
+ const { setStatusMessage } = useAppStore()
+
+ const loadProjects = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ const result = await window.api.overleafListProjects()
+ setLoading(false)
+ if (result.success && result.projects) {
+ setProjects(result.projects)
+ } else {
+ setError(result.message || 'Failed to load projects')
+ }
+ }, [])
+
+ useEffect(() => {
+ loadProjects()
+ }, [loadProjects])
+
+ const handleOpen = async (pid: string) => {
+ setError('')
+ setBusy(true)
+ setBusyText('Connecting to project...')
+ setStatusMessage('Connecting...')
+
+ const result = await window.api.otConnect(pid)
+ setBusy(false)
+
+ if (result.success) {
+ const store = useAppStore.getState()
+ if (result.files) store.setFiles(result.files as any)
+ if (result.project) store.setOverleafProject(result.project)
+ if (result.docPathMap && result.pathDocMap) store.setDocMaps(result.docPathMap, result.pathDocMap)
+ if (result.fileRefs) store.setFileRefs(result.fileRefs)
+ if (result.rootFolderId) store.setRootFolderId(result.rootFolderId)
+ store.setOverleafProjectId(pid)
+ setStatusMessage('Connected')
+ onOpenProject(pid)
+ } else {
+ setStatusMessage('Connection failed')
+ setError(result.message || 'Failed to connect')
+ }
+ }
+
+ const handleCreateProject = async () => {
+ const name = prompt('Project name:', 'Untitled Project')
+ if (!name?.trim()) return
+
+ setError('')
+ setBusy(true)
+ setBusyText('Creating project...')
+
+ const result = await window.api.overleafCreateProject(name.trim())
+ setBusy(false)
+
+ if (result.success && result.projectId) {
+ setStatusMessage(`Created "${name.trim()}"`)
+ loadProjects()
+ } else {
+ setError(result.message || 'Failed to create project')
+ }
+ }
+
+ const handleUploadProject = async () => {
+ setError('')
+ setBusy(true)
+ setBusyText('Uploading project...')
+
+ const result = await window.api.overleafUploadProject()
+ setBusy(false)
+
+ if (result.success && result.projectId) {
+ setStatusMessage('Project uploaded')
+ loadProjects()
+ } else if (result.message === 'cancelled') {
+ // user cancelled file dialog
+ } else {
+ setError(result.message || 'Failed to upload project')
+ }
+ }
+
+ const handleLogout = async () => {
+ await window.api.otDisconnect()
+ useAppStore.getState().resetEditorState()
+ useAppStore.getState().setScreen('login')
+ }
+
+ const toggleSort = (key: SortKey) => {
+ if (sortBy === key) {
+ setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc'))
+ } else {
+ setSortBy(key)
+ setSortOrder(key === 'name' ? 'asc' : 'desc')
+ }
+ }
+
+ const ownerName = (p: OverleafProject) => {
+ if (!p.owner) return ''
+ return `${p.owner.firstName} ${p.owner.lastName}`.trim()
+ }
+
+ const sortedAndFiltered = useMemo(() => {
+ let list = projects.filter((p) =>
+ p.name.toLowerCase().includes(searchFilter.toLowerCase())
+ )
+
+ list.sort((a, b) => {
+ let cmp = 0
+ if (sortBy === 'lastUpdated') {
+ cmp = new Date(a.lastUpdated).getTime() - new Date(b.lastUpdated).getTime()
+ } else if (sortBy === 'name') {
+ cmp = a.name.localeCompare(b.name)
+ } else if (sortBy === 'owner') {
+ cmp = ownerName(a).localeCompare(ownerName(b))
+ }
+ return sortOrder === 'asc' ? cmp : -cmp
+ })
+
+ return list
+ }, [projects, searchFilter, sortBy, sortOrder])
+
+ const formatDate = (d: string) => {
+ if (!d) return ''
+ try {
+ const date = new Date(d)
+ if (isNaN(date.getTime())) return ''
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffDays = Math.floor(diffMs / 86400000)
+ if (diffDays === 0) {
+ const diffH = Math.floor(diffMs / 3600000)
+ if (diffH === 0) {
+ const diffM = Math.floor(diffMs / 60000)
+ return diffM <= 1 ? 'Just now' : `${diffM}m ago`
+ }
+ return `${diffH}h ago`
+ }
+ if (diffDays === 1) return 'Yesterday'
+ if (diffDays < 7) return `${diffDays}d ago`
+ if (diffDays < 365) return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
+ } catch { return '' }
+ }
+
+ const personName = (p?: { firstName: string; lastName: string } | null) => {
+ if (!p) return ''
+ return `${p.firstName} ${p.lastName}`.trim()
+ }
+
+ const accessLabel = (level?: string) => {
+ switch (level) {
+ case 'owner': return 'Owner'
+ case 'readAndWrite': return 'Can edit'
+ case 'readOnly': return 'View only'
+ default: return level || ''
+ }
+ }
+
+ const sortIndicator = (key: SortKey) => {
+ if (sortBy !== key) return ''
+ return sortOrder === 'asc' ? ' ↑' : ' ↓'
+ }
+
+ return (
+ <div className="projects-page">
+ <div className="projects-drag-bar" />
+ <div className="projects-container">
+ <div className="projects-header">
+ <h1>ClaudeTeX</h1>
+ <div className="projects-header-actions">
+ <button className="btn btn-secondary btn-sm" onClick={handleLogout}>
+ Sign out
+ </button>
+ </div>
+ </div>
+
+ {error && <div className="overleaf-error" style={{ margin: '0 0 16px' }}>{error}</div>}
+
+ {busy ? (
+ <div className="projects-busy">
+ <div className="overleaf-spinner" />
+ <div className="overleaf-log">{busyText}</div>
+ </div>
+ ) : (
+ <>
+ <div className="projects-toolbar">
+ <input
+ className="projects-search"
+ type="text"
+ value={searchFilter}
+ onChange={(e) => setSearchFilter(e.target.value)}
+ placeholder="Search projects..."
+ autoFocus
+ />
+ <button className="btn btn-primary btn-sm" onClick={handleCreateProject}>
+ New Project
+ </button>
+ <button className="btn btn-secondary btn-sm" onClick={handleUploadProject}>
+ Upload
+ </button>
+ <button className="btn btn-secondary btn-sm" onClick={loadProjects} title="Refresh">
+ {loading ? '...' : '↻'}
+ </button>
+ </div>
+
+ <div className="projects-table-header">
+ <span className="projects-col-name" onClick={() => toggleSort('name')}>
+ Name{sortIndicator('name')}
+ </span>
+ <span className="projects-col-owner" onClick={() => toggleSort('owner')}>
+ Owner{sortIndicator('owner')}
+ </span>
+ <span className="projects-col-updated" onClick={() => toggleSort('lastUpdated')}>
+ Last Modified{sortIndicator('lastUpdated')}
+ </span>
+ </div>
+
+ <div className="projects-list">
+ {loading && projects.length === 0 ? (
+ <div className="projects-empty">Loading projects...</div>
+ ) : sortedAndFiltered.length === 0 ? (
+ <div className="projects-empty">
+ {searchFilter ? 'No matching projects' : 'No projects yet'}
+ </div>
+ ) : (
+ sortedAndFiltered.map((p) => (
+ <div
+ key={p.id}
+ className="projects-item"
+ onClick={() => handleOpen(p.id)}
+ >
+ <div className="projects-col-name">
+ <span className="projects-item-name">{p.name}</span>
+ {p.accessLevel && p.accessLevel !== 'owner' && (
+ <span className="projects-access-badge">{accessLabel(p.accessLevel)}</span>
+ )}
+ </div>
+ <div className="projects-col-owner">
+ {personName(p.owner)}
+ </div>
+ <div className="projects-col-updated">
+ <span className="projects-date">{formatDate(p.lastUpdated)}</span>
+ {p.lastUpdatedBy && (
+ <span className="projects-updated-by">by {personName(p.lastUpdatedBy)}</span>
+ )}
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/ReviewPanel.tsx b/src/renderer/src/components/ReviewPanel.tsx
new file mode 100644
index 0000000..fe94867
--- /dev/null
+++ b/src/renderer/src/components/ReviewPanel.tsx
@@ -0,0 +1,309 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { useAppStore } from '../stores/appStore'
+
+interface User {
+ id: string
+ first_name?: string
+ last_name?: string
+ email?: string
+}
+
+interface Message {
+ id: string
+ content: string
+ timestamp: number
+ user_id: string
+ user?: User
+}
+
+interface Thread {
+ messages: Message[]
+ resolved?: boolean
+ resolved_at?: string
+ resolved_by_user_id?: string
+ resolved_by_user?: User
+}
+
+type ThreadMap = Record<string, Thread>
+
+export default function ReviewPanel() {
+ const contexts = useAppStore((s) => s.commentContexts)
+ const activeTab = useAppStore((s) => s.activeTab)
+ const hoveredThreadId = useAppStore((s) => s.hoveredThreadId)
+ const focusedThreadId = useAppStore((s) => s.focusedThreadId)
+ const overleafProjectId = useAppStore((s) => s.overleafProjectId)
+ const [threads, setThreads] = useState<ThreadMap>({})
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [showResolved, setShowResolved] = useState(false)
+ const [replyingTo, setReplyingTo] = useState<string | null>(null)
+ const [replyText, setReplyText] = useState('')
+ const [editingMsg, setEditingMsg] = useState<{ threadId: string; messageId: string } | null>(null)
+ const [editText, setEditText] = useState('')
+ const threadRefs = useRef<Record<string, HTMLDivElement | null>>({})
+
+ const fetchThreads = useCallback(async () => {
+ if (!overleafProjectId) return
+ setLoading(true)
+ setError('')
+
+ const [threadResult, ctxResult] = await Promise.all([
+ window.api.overleafGetThreads(overleafProjectId),
+ window.api.otFetchAllCommentContexts()
+ ])
+
+ setLoading(false)
+ if (threadResult.success && threadResult.threads) {
+ setThreads(threadResult.threads as ThreadMap)
+ } else {
+ setError(threadResult.message || 'Failed to fetch comments')
+ }
+ if (ctxResult.success && ctxResult.contexts) {
+ useAppStore.getState().setCommentContexts(ctxResult.contexts)
+ }
+ }, [overleafProjectId])
+
+ useEffect(() => {
+ fetchThreads()
+ }, [fetchThreads])
+
+ useEffect(() => {
+ if (!focusedThreadId) return
+ const el = threadRefs.current[focusedThreadId]
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
+ }, [focusedThreadId])
+
+ const handleReply = async (threadId: string) => {
+ if (!replyText.trim() || !overleafProjectId) return
+ const result = await window.api.overleafReplyThread(overleafProjectId, threadId, replyText.trim())
+ if (result.success) {
+ setReplyText('')
+ setReplyingTo(null)
+ fetchThreads()
+ }
+ }
+
+ const handleResolve = async (threadId: string) => {
+ if (!overleafProjectId) return
+ await window.api.overleafResolveThread(overleafProjectId, threadId)
+ fetchThreads()
+ }
+
+ const handleReopen = async (threadId: string) => {
+ if (!overleafProjectId) return
+ await window.api.overleafReopenThread(overleafProjectId, threadId)
+ fetchThreads()
+ }
+
+ const handleDeleteMessage = async (threadId: string, messageId: string) => {
+ if (!overleafProjectId) return
+ await window.api.overleafDeleteMessage(overleafProjectId, threadId, messageId)
+ fetchThreads()
+ }
+
+ const handleStartEdit = (threadId: string, msg: Message) => {
+ setEditingMsg({ threadId, messageId: msg.id })
+ setEditText(msg.content)
+ }
+
+ const handleSaveEdit = async () => {
+ if (!editingMsg || !editText.trim() || !overleafProjectId) return
+ await window.api.overleafEditMessage(overleafProjectId, editingMsg.threadId, editingMsg.messageId, editText.trim())
+ setEditingMsg(null)
+ setEditText('')
+ fetchThreads()
+ }
+
+ const handleDeleteThread = async (threadId: string) => {
+ if (!overleafProjectId) return
+ const ctx = contexts[threadId]
+ const store = useAppStore.getState()
+ if (ctx) {
+ const docId = store.pathDocMap[ctx.file]
+ if (docId) {
+ await window.api.overleafDeleteThread(overleafProjectId, docId, threadId)
+ fetchThreads()
+ return
+ }
+ }
+ fetchThreads()
+ }
+
+ const getUserName = (msg: Message) => {
+ if (msg.user?.first_name) {
+ return msg.user.last_name ? `${msg.user.first_name} ${msg.user.last_name}` : msg.user.first_name
+ }
+ if (msg.user?.email) return msg.user.email.split('@')[0]
+ return msg.user_id.slice(-6)
+ }
+
+ const formatTime = (ts: number) => {
+ const d = new Date(ts)
+ const now = new Date()
+ const diffMs = now.getTime() - d.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ if (diffMins < 1) return 'just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ const diffHours = Math.floor(diffMins / 60)
+ if (diffHours < 24) return `${diffHours}h ago`
+ const diffDays = Math.floor(diffHours / 24)
+ if (diffDays < 7) return `${diffDays}d ago`
+ return d.toLocaleDateString()
+ }
+
+ // Navigate to comment position — always works for current file since it's already open
+ const handleClickContext = (threadId: string) => {
+ const ctx = contexts[threadId]
+ if (!ctx) return
+ const store = useAppStore.getState()
+ // File should already be open since we only show current file's comments
+ store.setPendingGoTo({ file: ctx.file, pos: ctx.pos, highlight: ctx.text })
+ }
+
+ if (!overleafProjectId) {
+ return (
+ <div className="review-panel">
+ <div className="review-header"><span>Review</span></div>
+ <div className="review-empty">Not connected</div>
+ </div>
+ )
+ }
+
+ // Filter threads to only show ones belonging to the current file
+ const threadEntries = Object.entries(threads)
+ const fileThreads = activeTab
+ ? threadEntries.filter(([threadId]) => {
+ const ctx = contexts[threadId]
+ return ctx && ctx.file === activeTab
+ })
+ : []
+ const activeThreads = fileThreads.filter(([, t]) => !t.resolved)
+ const resolvedThreads = fileThreads.filter(([, t]) => t.resolved)
+
+ const handleThreadHover = (threadId: string | null) => {
+ useAppStore.getState().setHoveredThreadId(threadId)
+ }
+
+ const renderThread = (threadId: string, thread: Thread, isResolved: boolean) => {
+ const ctx = contexts[threadId]
+ const isHighlighted = hoveredThreadId === threadId || focusedThreadId === threadId
+ return (
+ <div
+ key={threadId}
+ ref={(el) => { threadRefs.current[threadId] = el }}
+ className={`review-thread ${isResolved ? 'review-thread-resolved' : ''} ${isHighlighted ? 'review-thread-highlighted' : ''}`}
+ onMouseEnter={() => handleThreadHover(threadId)}
+ onMouseLeave={() => handleThreadHover(null)}
+ >
+ {ctx && ctx.text && (
+ <div className="review-context" onClick={() => handleClickContext(threadId)} title="Jump to position">
+ <span className="review-context-text">
+ &ldquo;{ctx.text.length > 80 ? ctx.text.slice(0, 80) + '...' : ctx.text}&rdquo;
+ </span>
+ </div>
+ )}
+ {thread.messages.map((msg, i) => {
+ const isEditing = editingMsg?.threadId === threadId && editingMsg?.messageId === msg.id
+ return (
+ <div key={msg.id || i} className={`review-message ${i === 0 ? 'review-message-first' : ''}`}>
+ <div className="review-message-header">
+ <span className="review-user">{getUserName(msg)}</span>
+ <div className="review-message-actions-inline">
+ <span className="review-time">{formatTime(msg.timestamp)}</span>
+ <button className="review-msg-action" onClick={() => handleStartEdit(threadId, msg)} title="Edit">&#9998;</button>
+ <button className="review-msg-action review-msg-delete" onClick={() => handleDeleteMessage(threadId, msg.id)} title="Delete">&times;</button>
+ </div>
+ </div>
+ {isEditing ? (
+ <div className="review-edit-inline">
+ <input
+ className="review-reply-input"
+ value={editText}
+ onChange={(e) => setEditText(e.target.value)}
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleSaveEdit()
+ if (e.key === 'Escape') setEditingMsg(null)
+ }}
+ />
+ <button className="review-reply-send" onClick={handleSaveEdit}>Save</button>
+ </div>
+ ) : (
+ <div className="review-message-content">{msg.content}</div>
+ )}
+ </div>
+ )
+ })}
+ <div className="review-thread-actions">
+ {!isResolved ? (
+ <>
+ <button className="review-action-btn" onClick={() => setReplyingTo(replyingTo === threadId ? null : threadId)}>Reply</button>
+ <button className="review-action-btn" onClick={() => handleResolve(threadId)}>Resolve</button>
+ <button className="review-action-btn review-action-delete" onClick={() => handleDeleteThread(threadId)}>Delete</button>
+ </>
+ ) : (
+ <>
+ <button className="review-action-btn" onClick={() => handleReopen(threadId)}>Reopen</button>
+ <button className="review-action-btn review-action-delete" onClick={() => handleDeleteThread(threadId)}>Delete</button>
+ </>
+ )}
+ </div>
+ {replyingTo === threadId && (
+ <div className="review-reply">
+ <input
+ className="review-reply-input"
+ value={replyText}
+ onChange={(e) => setReplyText(e.target.value)}
+ placeholder="Reply..."
+ autoFocus
+ onKeyDown={(e) => { if (e.key === 'Enter') handleReply(threadId) }}
+ />
+ <button className="review-reply-send" onClick={() => handleReply(threadId)}>Send</button>
+ </div>
+ )}
+ </div>
+ )
+ }
+
+ const fileName = activeTab?.split('/').pop() || ''
+
+ return (
+ <div className="review-panel">
+ <div className="review-header">
+ <span>{fileName ? `Review: ${fileName}` : 'Review'} ({activeThreads.length})</span>
+ <div className="review-header-actions">
+ <button className="toolbar-btn" onClick={fetchThreads} title="Refresh">
+ {loading ? '...' : '↻'}
+ </button>
+ {resolvedThreads.length > 0 && (
+ <button
+ className={`toolbar-btn ${showResolved ? 'active' : ''}`}
+ onClick={() => setShowResolved(!showResolved)}
+ title="Show resolved"
+ >
+ ✓ {resolvedThreads.length}
+ </button>
+ )}
+ </div>
+ </div>
+
+ {error && <div className="review-error">{error}</div>}
+
+ <div className="review-threads">
+ {!activeTab && (
+ <div className="review-empty">Open a file to see its comments</div>
+ )}
+ {activeTab && activeThreads.length === 0 && !loading && (
+ <div className="review-empty">No comments in this file</div>
+ )}
+ {activeThreads.map(([threadId, thread]) => renderThread(threadId, thread, false))}
+ {showResolved && resolvedThreads.length > 0 && (
+ <>
+ <div className="review-section-title">Resolved</div>
+ {resolvedThreads.map(([threadId, thread]) => renderThread(threadId, thread, true))}
+ </>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx
index cd11bdd..79b8a10 100644
--- a/src/renderer/src/components/StatusBar.tsx
+++ b/src/renderer/src/components/StatusBar.tsx
@@ -1,10 +1,19 @@
import { useAppStore } from '../stores/appStore'
export default function StatusBar() {
- const { statusMessage, isGitRepo, gitStatus, activeTab, compiling } = useAppStore()
+ const { statusMessage, activeTab, compiling, connectionState } = useAppStore()
const lineInfo = activeTab ? activeTab.split('/').pop() : ''
+ const connectionLabel = connectionState === 'connected' ? 'Connected'
+ : connectionState === 'connecting' ? 'Connecting...'
+ : connectionState === 'reconnecting' ? 'Reconnecting...'
+ : 'Disconnected'
+
+ const connectionDot = connectionState === 'connected' ? 'connection-dot-green'
+ : connectionState === 'connecting' || connectionState === 'reconnecting' ? 'connection-dot-yellow'
+ : 'connection-dot-red'
+
return (
<div className="status-bar">
<div className="status-left">
@@ -12,11 +21,10 @@ export default function StatusBar() {
<span className="status-message">{statusMessage}</span>
</div>
<div className="status-right">
- {isGitRepo && (
- <span className="status-git">
- Git{gitStatus ? ` (${gitStatus.split('\n').filter(Boolean).length} changes)` : ' (clean)'}
- </span>
- )}
+ <span className="status-connection">
+ <span className={`connection-dot ${connectionDot}`} />
+ {connectionLabel}
+ </span>
{lineInfo && <span className="status-file">{lineInfo}</span>}
<span className="status-encoding">UTF-8</span>
<span className="status-lang">LaTeX</span>
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index f7e306e..d84a00c 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -8,11 +8,10 @@ export default function Terminal() {
const termRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<XTerm | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
- const { projectPath } = useAppStore()
const [mode, setMode] = useState<'terminal' | 'claude'>('terminal')
useEffect(() => {
- if (!termRef.current || !projectPath) return
+ if (!termRef.current) return
const xterm = new XTerm({
theme: {
@@ -54,7 +53,7 @@ export default function Terminal() {
fitAddonRef.current = fitAddon
// Spawn shell
- window.api.ptySpawn(projectPath)
+ window.api.ptySpawn('/tmp')
// Pipe data
const unsubData = window.api.onPtyData((data) => {
@@ -86,7 +85,7 @@ export default function Terminal() {
window.api.ptyKill()
xterm.dispose()
}
- }, [projectPath])
+ }, [])
const launchClaude = () => {
if (!xtermRef.current) return
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx
index ac875bd..002b374 100644
--- a/src/renderer/src/components/Toolbar.tsx
+++ b/src/renderer/src/components/Toolbar.tsx
@@ -2,70 +2,50 @@ import { useAppStore } from '../stores/appStore'
interface ToolbarProps {
onCompile: () => void
- onSave: () => void
- onOpenProject: () => void
+ onBack: () => void
}
-export default function Toolbar({ onCompile, onSave, onOpenProject }: ToolbarProps) {
- const { projectPath, compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree, isGitRepo, mainDocument } = useAppStore()
- const projectName = projectPath?.split('/').pop() ?? 'ClaudeTeX'
+export default function Toolbar({ onCompile, onBack }: ToolbarProps) {
+ const {
+ compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree,
+ showReviewPanel, toggleReviewPanel, connectionState, overleafProject
+ } = useAppStore()
- const handlePull = async () => {
- if (!projectPath) return
- useAppStore.getState().setStatusMessage('Pulling from Overleaf...')
- const result = await window.api.gitPull(projectPath)
- useAppStore.getState().setStatusMessage(result.success ? 'Pull complete' : 'Pull failed')
- }
+ const projectName = overleafProject?.name || 'Project'
- const handlePush = async () => {
- if (!projectPath) return
- useAppStore.getState().setStatusMessage('Pushing to Overleaf...')
- const result = await window.api.gitPush(projectPath)
- useAppStore.getState().setStatusMessage(result.success ? 'Push complete' : 'Push failed')
- }
+ const connectionDot = connectionState === 'connected' ? 'connection-dot-green'
+ : connectionState === 'connecting' || connectionState === 'reconnecting' ? 'connection-dot-yellow'
+ : 'connection-dot-red'
return (
<div className="toolbar">
<div className="toolbar-left">
<div className="drag-region" />
- <button className="toolbar-btn" onClick={toggleFileTree} title="Toggle file tree (Cmd+\\)">
+ <button className="toolbar-btn" onClick={onBack} title="Back to projects">
+ &#8592;
+ </button>
+ <button className="toolbar-btn" onClick={toggleFileTree} title="Toggle file tree">
{showFileTree ? '◧' : '☰'}
</button>
- <span className="project-name">{projectName}</span>
+ <span className="project-name">
+ <span className={`connection-dot ${connectionDot}`} title={connectionState} />
+ {projectName}
+ </span>
</div>
<div className="toolbar-center">
- <button className="toolbar-btn" onClick={onOpenProject} title="Open project">
- Open
- </button>
- <button className="toolbar-btn" onClick={onSave} title="Save (Cmd+S)">
- Save
- </button>
<button
className={`toolbar-btn toolbar-btn-primary ${compiling ? 'compiling' : ''}`}
onClick={onCompile}
disabled={compiling}
- title={`Compile (Cmd+B)${mainDocument ? ' — ' + mainDocument.split('/').pop() : ''}`}
+ title="Compile (Cmd+B)"
>
{compiling ? 'Compiling...' : 'Compile'}
</button>
- {mainDocument && (
- <span className="toolbar-main-doc" title={mainDocument}>
- {mainDocument.split('/').pop()}
- </span>
- )}
- {isGitRepo && (
- <>
- <div className="toolbar-separator" />
- <button className="toolbar-btn" onClick={handlePull} title="Pull from Overleaf">
- Pull
- </button>
- <button className="toolbar-btn" onClick={handlePush} title="Push to Overleaf">
- Push
- </button>
- </>
- )}
</div>
<div className="toolbar-right">
+ <button className={`toolbar-btn ${showReviewPanel ? 'active' : ''}`} onClick={toggleReviewPanel} title="Toggle review panel">
+ Review
+ </button>
<button className="toolbar-btn" onClick={toggleTerminal} title="Toggle terminal (Cmd+`)">
{showTerminal ? 'Hide Terminal' : 'Terminal'}
</button>
diff --git a/src/renderer/src/extensions/addCommentTooltip.ts b/src/renderer/src/extensions/addCommentTooltip.ts
new file mode 100644
index 0000000..dc60bb1
--- /dev/null
+++ b/src/renderer/src/extensions/addCommentTooltip.ts
@@ -0,0 +1,97 @@
+/**
+ * CodeMirror extension: "Add comment" tooltip on text selection.
+ * Inspired by Overleaf's review-tooltip.ts.
+ */
+import {
+ EditorView,
+ showTooltip,
+ type Tooltip,
+} from '@codemirror/view'
+import {
+ StateField,
+ type EditorState,
+} from '@codemirror/state'
+
+export type AddCommentCallback = (from: number, to: number, text: string) => void
+
+let _addCommentCallback: AddCommentCallback | null = null
+
+export function setAddCommentCallback(cb: AddCommentCallback | null) {
+ _addCommentCallback = cb
+}
+
+function buildTooltip(state: EditorState): Tooltip | null {
+ const sel = state.selection.main
+ if (sel.empty) return null
+
+ return {
+ pos: sel.head,
+ above: sel.head < sel.anchor,
+ create() {
+ const dom = document.createElement('div')
+ dom.className = 'cm-add-comment-tooltip'
+
+ const btn = document.createElement('button')
+ btn.className = 'cm-add-comment-btn'
+ btn.textContent = '+ Comment'
+ btn.addEventListener('mousedown', (e) => {
+ e.preventDefault() // prevent editor losing focus/selection
+ })
+ btn.addEventListener('click', () => {
+ if (_addCommentCallback) {
+ const from = Math.min(sel.from, sel.to)
+ const to = Math.max(sel.from, sel.to)
+ const text = state.sliceDoc(from, to)
+ _addCommentCallback(from, to, text)
+ }
+ })
+
+ dom.appendChild(btn)
+ return { dom, overlap: true, offset: { x: 0, y: 4 } }
+ },
+ }
+}
+
+const addCommentTooltipField = StateField.define<Tooltip | null>({
+ create(state) {
+ return buildTooltip(state)
+ },
+ update(tooltip, tr) {
+ if (!tr.docChanged && !tr.selection) return tooltip
+ return buildTooltip(tr.state)
+ },
+ provide: (field) => showTooltip.from(field),
+})
+
+const addCommentTooltipTheme = EditorView.baseTheme({
+ '.cm-add-comment-tooltip.cm-tooltip': {
+ backgroundColor: 'transparent',
+ border: 'none',
+ zIndex: '10',
+ },
+ '.cm-add-comment-btn': {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '4px 10px',
+ fontSize: '12px',
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ fontWeight: '600',
+ color: '#5B4A28',
+ backgroundColor: '#FFF8E7',
+ border: '1px solid #D6CEBC',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
+ transition: 'background 0.15s',
+ },
+ '.cm-add-comment-btn:hover': {
+ backgroundColor: '#F5EDD6',
+ borderColor: '#B8A070',
+ },
+})
+
+export const addCommentTooltip = () => [
+ addCommentTooltipField,
+ addCommentTooltipTheme,
+]
diff --git a/src/renderer/src/extensions/commentHighlights.ts b/src/renderer/src/extensions/commentHighlights.ts
new file mode 100644
index 0000000..115c8fc
--- /dev/null
+++ b/src/renderer/src/extensions/commentHighlights.ts
@@ -0,0 +1,227 @@
+/**
+ * CodeMirror extension for highlighting commented text ranges.
+ * Inspired by Overleaf's ranges.ts — renders Decoration.mark for each comment
+ * in the current file, with hover/focus highlighting linkage to the ReviewPanel.
+ */
+import {
+ StateEffect,
+ StateField,
+} from '@codemirror/state'
+import {
+ Decoration,
+ type DecorationSet,
+ EditorView,
+ type PluginValue,
+ ViewPlugin,
+} from '@codemirror/view'
+
+// ── Types ──────────────────────────────────────────────────────
+
+export interface CommentRange {
+ threadId: string
+ from: number // character offset in the doc
+ to: number // from + text.length
+ text: string
+}
+
+// ── Effects ────────────────────────────────────────────────────
+
+/** Replace all comment ranges in the editor */
+export const setCommentRangesEffect = StateEffect.define<CommentRange[]>()
+
+/** Highlight a specific thread (from ReviewPanel hover) */
+export const highlightThreadEffect = StateEffect.define<string | null>()
+
+/** Focus a specific thread (from cursor position) — internal */
+const focusThreadEffect = StateEffect.define<string | null>()
+
+// ── State Fields ───────────────────────────────────────────────
+
+/** Stores comment ranges data */
+export const commentRangesField = StateField.define<CommentRange[]>({
+ create() {
+ return []
+ },
+ update(ranges, tr) {
+ for (const effect of tr.effects) {
+ if (effect.is(setCommentRangesEffect)) {
+ return effect.value
+ }
+ }
+ return ranges
+ },
+})
+
+/** Stores the currently highlighted thread ID (from panel hover) */
+const highlightedThreadField = StateField.define<string | null>({
+ create() {
+ return null
+ },
+ update(current, tr) {
+ for (const effect of tr.effects) {
+ if (effect.is(highlightThreadEffect)) {
+ return effect.value
+ }
+ }
+ return current
+ },
+})
+
+/** Stores the currently focused thread ID (from cursor position) */
+const focusedThreadField = StateField.define<string | null>({
+ create() {
+ return null
+ },
+ update(current, tr) {
+ for (const effect of tr.effects) {
+ if (effect.is(focusThreadEffect)) {
+ return effect.value
+ }
+ }
+ return current
+ },
+})
+
+// ── Decoration Builders ────────────────────────────────────────
+
+function buildCommentDecorations(ranges: CommentRange[]): DecorationSet {
+ if (ranges.length === 0) return Decoration.none
+
+ const decorations = []
+ for (const r of ranges) {
+ if (r.from >= r.to) continue
+ decorations.push(
+ Decoration.mark({
+ class: 'cm-comment-highlight',
+ attributes: { 'data-thread-id': r.threadId },
+ }).range(r.from, r.to)
+ )
+ }
+ // Must be sorted by from position
+ decorations.sort((a, b) => a.from - b.from)
+ return Decoration.set(decorations, true)
+}
+
+function buildHighlightDecoration(ranges: CommentRange[], threadId: string | null): DecorationSet {
+ if (!threadId) return Decoration.none
+ const r = ranges.find(c => c.threadId === threadId)
+ if (!r || r.from >= r.to) return Decoration.none
+ return Decoration.set([
+ Decoration.mark({ class: 'cm-comment-highlight-hover' }).range(r.from, r.to)
+ ])
+}
+
+function buildFocusDecoration(ranges: CommentRange[], threadId: string | null): DecorationSet {
+ if (!threadId) return Decoration.none
+ const r = ranges.find(c => c.threadId === threadId)
+ if (!r || r.from >= r.to) return Decoration.none
+ return Decoration.set([
+ Decoration.mark({ class: 'cm-comment-highlight-focus' }).range(r.from, r.to)
+ ])
+}
+
+// ── View Plugins ───────────────────────────────────────────────
+
+/** Base comment decorations (yellow background) */
+const commentDecorationsPlugin = ViewPlugin.define<PluginValue & { decorations: DecorationSet }>(
+ () => ({
+ decorations: Decoration.none,
+ update(update) {
+ for (const tr of update.transactions) {
+ this.decorations = this.decorations.map(tr.changes)
+ for (const effect of tr.effects) {
+ if (effect.is(setCommentRangesEffect)) {
+ this.decorations = buildCommentDecorations(effect.value)
+ }
+ }
+ }
+ },
+ }),
+ { decorations: (v) => v.decorations }
+)
+
+/** Hover highlight decoration (stronger yellow, from ReviewPanel hover) */
+const hoverHighlightPlugin = ViewPlugin.define<PluginValue & { decorations: DecorationSet }>(
+ (view) => ({
+ decorations: Decoration.none,
+ update(update) {
+ for (const tr of update.transactions) {
+ for (const effect of tr.effects) {
+ if (effect.is(highlightThreadEffect) || effect.is(setCommentRangesEffect)) {
+ const ranges = update.state.field(commentRangesField)
+ const threadId = update.state.field(highlightedThreadField)
+ this.decorations = buildHighlightDecoration(ranges, threadId)
+ return
+ }
+ }
+ this.decorations = this.decorations.map(tr.changes)
+ }
+ },
+ }),
+ { decorations: (v) => v.decorations }
+)
+
+/** Focus decoration (border, from cursor position in comment range) */
+const focusHighlightPlugin = ViewPlugin.define<PluginValue & { decorations: DecorationSet }>(
+ () => ({
+ decorations: Decoration.none,
+ update(update) {
+ const needsRebuild = update.selectionSet ||
+ update.transactions.some(tr =>
+ tr.effects.some(e => e.is(setCommentRangesEffect))
+ )
+
+ if (!needsRebuild) {
+ this.decorations = this.decorations.map(update.changes)
+ return
+ }
+
+ const ranges = update.state.field(commentRangesField)
+ const cursorPos = update.state.selection.main.head
+
+ let foundThreadId: string | null = null
+ for (const r of ranges) {
+ if (cursorPos >= r.from && cursorPos <= r.to) {
+ foundThreadId = r.threadId
+ break
+ }
+ }
+
+ this.decorations = buildFocusDecoration(ranges, foundThreadId)
+ },
+ }),
+ { decorations: (v) => v.decorations }
+)
+
+// ── Theme ──────────────────────────────────────────────────────
+
+const commentHighlightTheme = EditorView.baseTheme({
+ '.cm-comment-highlight': {
+ backgroundColor: 'rgba(243, 177, 17, 0.25)',
+ borderBottom: '2px solid rgba(243, 177, 17, 0.5)',
+ padding: '1px 0',
+ cursor: 'pointer',
+ },
+ '.cm-comment-highlight-hover': {
+ backgroundColor: 'rgba(243, 177, 17, 0.45)',
+ borderBottom: '2px solid rgba(243, 177, 17, 0.8)',
+ },
+ '.cm-comment-highlight-focus': {
+ backgroundColor: 'rgba(243, 177, 17, 0.45)',
+ borderBottom: '2px solid rgba(200, 140, 0, 1)',
+ outline: '1px solid rgba(200, 140, 0, 0.3)',
+ borderRadius: '2px',
+ },
+})
+
+// ── Export Extension ───────────────────────────────────────────
+
+export const commentHighlights = () => [
+ commentRangesField,
+ highlightedThreadField,
+ focusedThreadField,
+ commentDecorationsPlugin,
+ hoverHighlightPlugin,
+ focusHighlightPlugin,
+ commentHighlightTheme,
+]
diff --git a/src/renderer/src/extensions/otSyncExtension.ts b/src/renderer/src/extensions/otSyncExtension.ts
new file mode 100644
index 0000000..7ff2203
--- /dev/null
+++ b/src/renderer/src/extensions/otSyncExtension.ts
@@ -0,0 +1,39 @@
+// CM6 extension for OT sync: ViewPlugin + annotation to prevent echo loops
+import { Annotation } from '@codemirror/state'
+import { ViewPlugin, type ViewUpdate } from '@codemirror/view'
+import type { OverleafDocSync } from '../ot/overleafSync'
+
+/** Annotation used to mark transactions that come from remote OT updates */
+export const remoteUpdateAnnotation = Annotation.define<boolean>()
+
+/**
+ * Creates a CM6 extension that intercepts local doc changes
+ * and feeds them to the OT orchestrator. Skips changes tagged
+ * with remoteUpdateAnnotation to prevent echo loops.
+ */
+export function otSyncExtension(sync: OverleafDocSync) {
+ return ViewPlugin.fromClass(
+ class {
+ constructor() {
+ // nothing to initialize
+ }
+
+ update(update: ViewUpdate) {
+ if (!update.docChanged) return
+
+ // Skip if this change was from a remote OT update
+ for (const tr of update.transactions) {
+ if (tr.annotation(remoteUpdateAnnotation)) return
+ }
+
+ // Feed local changes to OT orchestrator
+ // We need the old doc (before changes) — it's the startState.doc
+ sync.onLocalChange(update.changes, update.startState.doc)
+ }
+
+ destroy() {
+ // nothing to clean up
+ }
+ }
+ )
+}
diff --git a/src/renderer/src/ot/cmAdapter.ts b/src/renderer/src/ot/cmAdapter.ts
new file mode 100644
index 0000000..852402d
--- /dev/null
+++ b/src/renderer/src/ot/cmAdapter.ts
@@ -0,0 +1,70 @@
+// Bidirectional conversion: CM6 ChangeSet <-> Overleaf OT ops
+import type { ChangeSet, Text, ChangeSpec } from '@codemirror/state'
+import type { OtOp, InsertOp, DeleteOp } from './types'
+import { isInsert, isDelete } from './types'
+
+/**
+ * Convert a CM6 ChangeSet into Overleaf OT ops.
+ * Iterates through the changes and produces insert/delete ops
+ * with positions relative to the old document.
+ */
+export function changeSetToOtOps(changes: ChangeSet, oldDoc: Text): OtOp[] {
+ const ops: OtOp[] = []
+ let posAdjust = 0 // tracks position shift from previous ops
+
+ changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
+ const origFrom = fromA
+ const deletedLen = toA - fromA
+ const insertedText = inserted.toString()
+
+ // Delete first (at original position in the old doc)
+ if (deletedLen > 0) {
+ const deletedText = oldDoc.sliceString(fromA, toA)
+ ops.push({ d: deletedText, p: origFrom + posAdjust })
+ // After deleting, subsequent positions shift back
+ }
+
+ // Then insert
+ if (insertedText.length > 0) {
+ ops.push({ i: insertedText, p: origFrom + posAdjust })
+ posAdjust += insertedText.length
+ }
+
+ if (deletedLen > 0) {
+ posAdjust -= deletedLen
+ }
+ })
+
+ return ops
+}
+
+/**
+ * Convert Overleaf OT ops into CM6 ChangeSpec array.
+ * These can be dispatched to an EditorView.
+ */
+export function otOpsToChangeSpec(ops: OtOp[]): ChangeSpec[] {
+ const specs: ChangeSpec[] = []
+ // Sort ops by position (process in order). Inserts before deletes at same position.
+ const sorted = [...ops].filter(op => isInsert(op) || isDelete(op)).sort((a, b) => {
+ if (a.p !== b.p) return a.p - b.p
+ // Inserts before deletes at same position
+ if (isInsert(a) && isDelete(b)) return -1
+ if (isDelete(a) && isInsert(b)) return 1
+ return 0
+ })
+
+ // We need to adjust positions as we apply ops sequentially
+ let posShift = 0
+
+ for (const op of sorted) {
+ if (isInsert(op)) {
+ specs.push({ from: op.p + posShift, insert: op.i })
+ posShift += op.i.length
+ } else if (isDelete(op)) {
+ specs.push({ from: op.p + posShift, to: op.p + posShift + op.d.length })
+ posShift -= op.d.length
+ }
+ }
+
+ return specs
+}
diff --git a/src/renderer/src/ot/otClient.ts b/src/renderer/src/ot/otClient.ts
new file mode 100644
index 0000000..a491d23
--- /dev/null
+++ b/src/renderer/src/ot/otClient.ts
@@ -0,0 +1,135 @@
+// OT state machine: Synchronized / AwaitingConfirm / AwaitingWithBuffer
+import type { OtOp, OtState } from './types'
+import { transformOps } from './transform'
+
+export type SendFn = (ops: OtOp[], version: number) => void
+export type ApplyFn = (ops: OtOp[]) => void
+
+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
+ }
+
+ /** Called when local user makes changes */
+ onLocalOps(ops: OtOp[]) {
+ if (ops.length === 0) return
+
+ switch (this.state.name) {
+ case 'synchronized':
+ // Send immediately, transition to awaiting
+ this.state = {
+ name: 'awaitingConfirm',
+ inflight: ops,
+ buffer: null,
+ version: this.state.version
+ }
+ this.sendFn(ops, this.state.version)
+ break
+
+ case 'awaitingConfirm':
+ // Buffer the ops
+ this.state = {
+ name: 'awaitingWithBuffer',
+ inflight: this.state.inflight,
+ buffer: ops,
+ version: this.state.version
+ }
+ break
+
+ case 'awaitingWithBuffer':
+ // Compose into existing buffer
+ this.state = {
+ ...this.state,
+ buffer: [...(this.state.buffer || []), ...ops]
+ }
+ break
+ }
+ }
+
+ /** Called when server acknowledges our inflight ops */
+ onAck() {
+ switch (this.state.name) {
+ case 'awaitingConfirm':
+ this.state = {
+ name: 'synchronized',
+ inflight: null,
+ buffer: null,
+ version: this.state.version + 1
+ }
+ break
+
+ case 'awaitingWithBuffer':
+ // Send the buffer, move to awaitingConfirm
+ 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':
+ // Unexpected ack in synchronized state, ignore
+ console.warn('[OtClient] unexpected ack in synchronized state')
+ break
+ }
+ }
+
+ /** Called when server sends a remote operation */
+ onRemoteOps(ops: OtOp[], newVersion: number) {
+ switch (this.state.name) {
+ case 'synchronized':
+ // Apply directly
+ this.state = { ...this.state, version: newVersion }
+ this.applyFn(ops)
+ break
+
+ case 'awaitingConfirm': {
+ // Transform: remote ops vs our inflight
+ const { left: transformedRemote, right: transformedInflight } = transformOps(ops, this.state.inflight || [])
+ this.state = {
+ ...this.state,
+ inflight: transformedInflight,
+ version: newVersion
+ }
+ this.applyFn(transformedRemote)
+ break
+ }
+
+ case 'awaitingWithBuffer': {
+ // Transform remote vs inflight, then remote' vs buffer
+ 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 to a known version (e.g. after reconnect) */
+ reset(version: number) {
+ this.state = { name: 'synchronized', inflight: null, buffer: null, version }
+ }
+}
diff --git a/src/renderer/src/ot/overleafSync.ts b/src/renderer/src/ot/overleafSync.ts
new file mode 100644
index 0000000..e6169fc
--- /dev/null
+++ b/src/renderer/src/ot/overleafSync.ts
@@ -0,0 +1,147 @@
+// Per-document orchestrator: ties CM6 adapter to OT client, IPC bridge
+import type { EditorView } from '@codemirror/view'
+import { ChangeSet, Transaction, type Text } from '@codemirror/state'
+import { OtClient } from './otClient'
+import type { OtOp } from './types'
+import { changeSetToOtOps, otOpsToChangeSpec } from './cmAdapter'
+import { remoteUpdateAnnotation } from '../extensions/otSyncExtension'
+
+function sha1(text: string): string {
+ return window.api.sha1(text)
+}
+
+export class OverleafDocSync {
+ private otClient: OtClient
+ private view: EditorView | null = null
+ private docId: string
+ private pendingChanges: ChangeSet | null = null
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
+ private debounceMs = 150
+
+ constructor(docId: string, version: number) {
+ this.docId = docId
+ this.otClient = new OtClient(
+ version,
+ this.handleSend.bind(this),
+ this.handleApply.bind(this)
+ )
+ }
+
+ get version(): number {
+ return this.otClient.version
+ }
+
+ setView(view: EditorView) {
+ this.view = view
+ }
+
+ /** Called by CM6 update listener for local changes */
+ onLocalChange(changes: ChangeSet, oldDoc: Text) {
+ // Compose into pending changes (buffer ChangeSets, convert to OT ops only at send time)
+ if (this.pendingChanges) {
+ this.pendingChanges = this.pendingChanges.compose(changes)
+ } else {
+ this.pendingChanges = changes
+ }
+
+ // Debounce send
+ if (this.debounceTimer) clearTimeout(this.debounceTimer)
+ this.debounceTimer = setTimeout(() => this.flushLocalChanges(), this.debounceMs)
+ }
+
+ private flushLocalChanges() {
+ if (!this.pendingChanges || !this.view) return
+
+ const oldDoc = this.view.state.doc
+ // We need the doc state BEFORE the pending changes were applied
+ // Since we composed changes incrementally, we work backward
+ // Actually, we stored the ChangeSet which maps old positions, so we convert directly
+ const ops = changeSetToOtOps(this.pendingChanges, this.getOldDoc())
+ this.pendingChanges = null
+
+ if (ops.length > 0) {
+ this.otClient.onLocalOps(ops)
+ }
+ }
+
+ private getOldDoc(): Text {
+ // The "old doc" is the current doc minus pending local changes
+ // Since pendingChanges is null at send time (we just cleared it),
+ // and the ChangeSet was already composed against the old doc,
+ // we just use the doc that was current when changes started accumulating.
+ // For simplicity, we pass the doc at change time via changeSetToOtOps
+ return this.view!.state.doc
+ }
+
+ /** Send ops to server via IPC */
+ private handleSend(ops: OtOp[], version: number) {
+ const docText = this.view?.state.doc.toString() || ''
+ const hash = sha1(docText)
+ window.api.otSendOp(this.docId, ops, version, hash)
+ }
+
+ /** Apply remote ops to CM6 editor */
+ private handleApply(ops: OtOp[]) {
+ if (!this.view) return
+
+ const specs = otOpsToChangeSpec(ops)
+ if (specs.length === 0) return
+
+ this.view.dispatch({
+ changes: specs,
+ annotations: [
+ remoteUpdateAnnotation.of(true),
+ Transaction.addToHistory.of(false)
+ ]
+ })
+ }
+
+ /** Called when server acknowledges our ops */
+ onAck() {
+ this.otClient.onAck()
+ }
+
+ /** Called when server sends remote ops */
+ onRemoteOps(ops: OtOp[], version: number) {
+ this.otClient.onRemoteOps(ops, version)
+ }
+
+ /** Reset after reconnect with fresh doc state */
+ reset(version: number, docContent: string) {
+ this.otClient.reset(version)
+ this.pendingChanges = null
+ if (this.debounceTimer) {
+ clearTimeout(this.debounceTimer)
+ this.debounceTimer = null
+ }
+ // Replace editor content with server state
+ if (this.view) {
+ this.view.dispatch({
+ changes: { from: 0, to: this.view.state.doc.length, insert: docContent },
+ annotations: [
+ remoteUpdateAnnotation.of(true),
+ Transaction.addToHistory.of(false)
+ ]
+ })
+ }
+ }
+
+ /** Replace entire editor content with new content (external edit from disk) */
+ replaceContent(newContent: string) {
+ if (!this.view) return
+
+ const currentContent = this.view.state.doc.toString()
+ if (currentContent === newContent) return
+
+ // Dispatch as a local change (NOT remote annotation) so it flows through OT
+ this.view.dispatch({
+ changes: { from: 0, to: this.view.state.doc.length, insert: newContent }
+ })
+ }
+
+ destroy() {
+ if (this.debounceTimer) clearTimeout(this.debounceTimer)
+ this.view = null
+ this.pendingChanges = null
+ }
+}
diff --git a/src/renderer/src/ot/transform.ts b/src/renderer/src/ot/transform.ts
new file mode 100644
index 0000000..846e312
--- /dev/null
+++ b/src/renderer/src/ot/transform.ts
@@ -0,0 +1,174 @@
+// OT transform functions for Overleaf's text operation format
+import type { OtOp } from './types'
+import { isInsert, isDelete, isComment } from './types'
+
+/**
+ * Transform two lists of operations against each other.
+ * Returns { left, right } where:
+ * - left = ops1 transformed against ops2 (apply after ops2)
+ * - right = ops2 transformed against ops1 (apply after ops1)
+ */
+export function transformOps(
+ ops1: OtOp[],
+ ops2: OtOp[]
+): { left: OtOp[]; right: OtOp[] } {
+ let left = ops1
+ let right = ops2
+
+ // Transform each op in left against all ops in right, and vice versa
+ const newLeft: OtOp[] = []
+ for (const op1 of left) {
+ 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 }
+}
+
+/** Transform a single op against another single op */
+function transformOp(op1: OtOp, op2: OtOp): { left: OtOp; right: OtOp } {
+ // Insert vs Insert
+ 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
+ }
+ }
+ }
+
+ // Insert vs Delete
+ 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 {
+ // Insert inside deleted region — place at delete position
+ return {
+ left: { ...op1, p: op2.p },
+ right: op2
+ }
+ }
+ }
+
+ // Delete vs Insert
+ 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 {
+ // Insert inside our deleted region
+ return {
+ left: op1,
+ right: { ...op2, p: op2.p - op1.d.length }
+ }
+ }
+ }
+
+ // Delete vs Delete
+ 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 {
+ // Overlapping deletes — both become no-ops for the overlapping part
+ const start = Math.max(op1.p, op2.p)
+ const end1 = op1.p + op1.d.length
+ const end2 = op2.p + op2.d.length
+
+ // op1 after removing overlap with op2
+ let newOp1Text = op1.d
+ const overlapStart = Math.max(0, op2.p - op1.p)
+ const overlapEnd = Math.min(op1.d.length, op2.p + op2.d.length - op1.p)
+ if (overlapEnd > overlapStart) {
+ newOp1Text = op1.d.slice(0, overlapStart) + op1.d.slice(overlapEnd)
+ }
+
+ let newOp2Text = op2.d
+ const overlapStart2 = Math.max(0, op1.p - op2.p)
+ const overlapEnd2 = Math.min(op2.d.length, op1.p + op1.d.length - op2.p)
+ 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 }
+ }
+ }
+ }
+
+ // Comment ops: treat like inserts of zero length at their position for transform purposes
+ if (isComment(op1) || isComment(op2)) {
+ // Comments don't modify the document text, so they just need position adjustment
+ let p1 = isComment(op1) ? op1.p : ('p' in op1 ? op1.p : 0)
+ let p2 = isComment(op2) ? op2.p : ('p' in op2 ? op2.p : 0)
+
+ if (isInsert(op2) && !isComment(op1)) {
+ // handled above
+ }
+
+ // For comments, adjust position based on the other op
+ 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 } }
+ }
+ }
+
+ // Both comments or no positional conflict
+ return { left: op1, right: op2 }
+ }
+
+ // Fallback: no transform needed
+ return { left: op1, right: op2 }
+}
diff --git a/src/renderer/src/ot/types.ts b/src/renderer/src/ot/types.ts
new file mode 100644
index 0000000..2732e4f
--- /dev/null
+++ b/src/renderer/src/ot/types.ts
@@ -0,0 +1,53 @@
+// OT type definitions for Overleaf's text operation format
+
+/** Insert text at position p */
+export interface InsertOp {
+ i: string
+ p: number
+}
+
+/** Delete text at position p */
+export interface DeleteOp {
+ d: string
+ p: number
+}
+
+/** Comment operation (mark text at position p) */
+export interface CommentOp {
+ c: string
+ p: number
+ t: string // threadId
+}
+
+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
+}
+
+/** A versioned OT update */
+export interface OtUpdate {
+ doc: string
+ op: OtOp[]
+ v: number
+ hash?: string
+ lastV?: number
+}
+
+/** Possible states of the OT client */
+export type OtStateName = 'synchronized' | 'awaitingConfirm' | 'awaitingWithBuffer'
+
+export interface OtState {
+ name: OtStateName
+ inflight: OtOp[] | null // ops sent, awaiting ack
+ buffer: OtOp[] | null // ops queued while awaiting
+ version: number
+}
diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts
index 6476239..9e8e0d0 100644
--- a/src/renderer/src/stores/appStore.ts
+++ b/src/renderer/src/stores/appStore.ts
@@ -1,22 +1,36 @@
import { create } from 'zustand'
-interface FileNode {
+export interface FileNode {
name: string
path: string
isDir: boolean
children?: FileNode[]
+ docId?: string
+ fileRefId?: string
+ folderId?: string
}
+export type SocketConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
+
+/** Which screen is currently active */
+export type AppScreen = 'login' | 'projects' | 'editor'
+
interface OpenTab {
path: string
name: string
modified: boolean
}
+export interface CommentContext {
+ file: string
+ text: string
+ pos: number
+}
+
interface AppState {
- // Project
- projectPath: string | null
- setProjectPath: (p: string | null) => void
+ // Screen
+ screen: AppScreen
+ setScreen: (s: AppScreen) => void
// File tree
files: FileNode[]
@@ -34,7 +48,7 @@ interface AppState {
fileContents: Record<string, string>
setFileContent: (path: string, content: string) => void
- // Main document
+ // Main document (rootDocId)
mainDocument: string | null
setMainDocument: (p: string | null) => void
@@ -55,24 +69,54 @@ interface AppState {
showFileTree: boolean
toggleFileTree: () => void
- // Git/Overleaf
- isGitRepo: boolean
- setIsGitRepo: (v: boolean) => void
- gitStatus: string
- setGitStatus: (s: string) => void
-
- // Navigation (from log click → editor)
- pendingGoTo: { file: string; line: number } | null
- setPendingGoTo: (g: { file: string; line: number } | null) => void
+ // Overleaf
+ overleafProjectId: string | null
+ setOverleafProjectId: (id: string | null) => void
+
+ // Socket connection
+ connectionState: SocketConnectionState
+ setConnectionState: (s: SocketConnectionState) => void
+ docPathMap: Record<string, string> // docId → relativePath
+ pathDocMap: Record<string, string> // relativePath → docId
+ setDocMaps: (docPath: Record<string, string>, pathDoc: Record<string, string>) => void
+ docVersions: Record<string, number> // docId → version
+ setDocVersion: (docId: string, version: number) => void
+ overleafProject: { name: string; rootDocId: string } | null
+ setOverleafProject: (p: { name: string; rootDocId: string } | null) => void
+ fileRefs: Array<{ id: string; path: string }>
+ setFileRefs: (refs: Array<{ id: string; path: string }>) => void
+ rootFolderId: string
+ setRootFolderId: (id: string) => void
+
+ // Review panel
+ showReviewPanel: boolean
+ toggleReviewPanel: () => void
+
+ // Comment data
+ commentContexts: Record<string, CommentContext>
+ setCommentContexts: (c: Record<string, CommentContext>) => void
+ overleafDocs: Record<string, string>
+ setOverleafDocs: (d: Record<string, string>) => void
+ hoveredThreadId: string | null
+ setHoveredThreadId: (id: string | null) => void
+ focusedThreadId: string | null
+ setFocusedThreadId: (id: string | null) => void
+
+ // Navigation
+ pendingGoTo: { file: string; line?: number; pos?: number; highlight?: string } | null
+ setPendingGoTo: (g: { file: string; line?: number; pos?: number; highlight?: string } | null) => void
// Status
statusMessage: string
setStatusMessage: (m: string) => void
+
+ // Reset editor state (when going back to project list)
+ resetEditorState: () => void
}
export const useAppStore = create<AppState>((set) => ({
- projectPath: null,
- setProjectPath: (p) => set({ projectPath: p }),
+ screen: 'login',
+ setScreen: (s) => set({ screen: s }),
files: [],
setFiles: (f) => set({ files: f }),
@@ -126,14 +170,64 @@ export const useAppStore = create<AppState>((set) => ({
showFileTree: true,
toggleFileTree: () => set((s) => ({ showFileTree: !s.showFileTree })),
- isGitRepo: false,
- setIsGitRepo: (v) => set({ isGitRepo: v }),
- gitStatus: '',
- setGitStatus: (s) => set({ gitStatus: s }),
+ overleafProjectId: null,
+ setOverleafProjectId: (id) => set({ overleafProjectId: id }),
+
+ connectionState: 'disconnected',
+ setConnectionState: (s) => set({ connectionState: s }),
+ docPathMap: {},
+ pathDocMap: {},
+ setDocMaps: (docPath, pathDoc) => set({ docPathMap: docPath, pathDocMap: pathDoc }),
+ docVersions: {},
+ setDocVersion: (docId, version) =>
+ set((s) => ({ docVersions: { ...s.docVersions, [docId]: version } })),
+ overleafProject: null,
+ setOverleafProject: (p) => set({ overleafProject: p }),
+ fileRefs: [],
+ setFileRefs: (refs) => set({ fileRefs: refs }),
+ rootFolderId: '',
+ setRootFolderId: (id) => set({ rootFolderId: id }),
+
+ showReviewPanel: false,
+ toggleReviewPanel: () => set((s) => ({ showReviewPanel: !s.showReviewPanel })),
+
+ commentContexts: {},
+ setCommentContexts: (c) => set({ commentContexts: c }),
+ overleafDocs: {},
+ setOverleafDocs: (d) => set({ overleafDocs: d }),
+ hoveredThreadId: null,
+ setHoveredThreadId: (id) => set({ hoveredThreadId: id }),
+ focusedThreadId: null,
+ setFocusedThreadId: (id) => set({ focusedThreadId: id }),
pendingGoTo: null,
setPendingGoTo: (g) => set({ pendingGoTo: g }),
statusMessage: 'Ready',
- setStatusMessage: (m) => set({ statusMessage: m })
+ setStatusMessage: (m) => set({ statusMessage: m }),
+
+ resetEditorState: () => set({
+ files: [],
+ openTabs: [],
+ activeTab: null,
+ fileContents: {},
+ mainDocument: null,
+ pdfPath: null,
+ compileLog: '',
+ compiling: false,
+ overleafProjectId: null,
+ connectionState: 'disconnected',
+ docPathMap: {},
+ pathDocMap: {},
+ docVersions: {},
+ overleafProject: null,
+ fileRefs: [],
+ rootFolderId: '',
+ commentContexts: {},
+ overleafDocs: {},
+ hoveredThreadId: null,
+ focusedThreadId: null,
+ pendingGoTo: null,
+ statusMessage: 'Ready'
+ })
}))