summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/fileSyncBridge.ts6
-rw-r--r--src/main/index.ts192
-rw-r--r--src/preload/index.ts8
-rw-r--r--src/renderer/src/App.css55
-rw-r--r--src/renderer/src/App.tsx30
-rw-r--r--src/renderer/src/components/Editor.tsx3
-rw-r--r--src/renderer/src/components/PdfViewer.tsx38
-rw-r--r--src/renderer/src/components/ReviewPanel.tsx12
-rw-r--r--src/renderer/src/components/Toolbar.tsx57
-rw-r--r--src/renderer/src/stores/appStore.ts8
10 files changed, 365 insertions, 44 deletions
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts
index 7763521..fb45208 100644
--- a/src/main/fileSyncBridge.ts
+++ b/src/main/fileSyncBridge.ts
@@ -138,7 +138,7 @@ export class FileSyncBridge {
atomic: true,
ignored: [
/(^|[/\\])\../, // dotfiles
- /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb)$/ // LaTeX output files (not pdf!)
+ /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/ // LaTeX output files
]
})
@@ -832,7 +832,7 @@ export class FileSyncBridge {
for (const relPath of allFiles) {
if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) continue
// Skip LaTeX output files
- if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|synctex)/.test(relPath)) continue
+ if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex|synctex)/.test(relPath)) continue
if (/(^|[/\\])\./.test(relPath)) continue
bridgeLog(`[FileSyncBridge] orphaned file found: ${relPath}`)
@@ -851,7 +851,7 @@ export class FileSyncBridge {
if (this.writesInProgress.has(relPath)) return
// Skip LaTeX output files and dotfiles (same as chokidar ignored)
- if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb)$/.test(relPath)) return
+ if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/.test(relPath)) return
if (/(^|[/\\])\./.test(relPath)) return
// Debounce 1s to let the tool finish writing
diff --git a/src/main/index.ts b/src/main/index.ts
index 6e0b40b..0956774 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -10,6 +10,10 @@ import { OverleafSocket, type RootFolder, type SubFolder, type JoinDocResult } f
import { CompilationManager } from './compilationManager'
import { FileSyncBridge } from './fileSyncBridge'
+// Prevent EPIPE crashes when stdout/stderr is closed (e.g. Electron launched from Finder)
+process.stdout?.on('error', () => {})
+process.stderr?.on('error', () => {})
+
let mainWindow: BrowserWindow | null = null
const ptyInstances = new Map<string, pty.IPty>()
let overleafSock: OverleafSocket | null = null
@@ -87,8 +91,10 @@ for (const p of texPaths) {
// 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) => {
+ const pdfDir = pdfPath.substring(0, pdfPath.lastIndexOf('/'))
const proc = spawn('synctex', ['edit', '-o', `${page}:${x}:${y}:${pdfPath}`], {
- env: process.env
+ env: process.env,
+ cwd: pdfDir
})
let out = ''
proc.stdout?.on('data', (d) => { out += d.toString() })
@@ -98,7 +104,15 @@ ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number,
const fileMatch = out.match(/Input:(.+)/)
const lineMatch = out.match(/Line:(\d+)/)
if (fileMatch && lineMatch) {
- resolve({ file: fileMatch[1].trim(), line: parseInt(lineMatch[1]) })
+ let filePath = fileMatch[1].trim()
+ // Convert absolute path to relative (strip tmpDir prefix)
+ const syncDir = compilationManager?.dir
+ if (syncDir && filePath.startsWith(syncDir)) {
+ filePath = filePath.slice(syncDir.length).replace(/^\//, '')
+ }
+ // Strip leading ./
+ if (filePath.startsWith('./')) filePath = filePath.slice(2)
+ resolve({ file: filePath, line: parseInt(lineMatch[1]) })
} else {
console.log('[synctex] no result:', out.slice(0, 200))
resolve(null)
@@ -683,12 +697,9 @@ You have MCP tools to interact with Overleaf. Use them proactively.
}, null, 2))
).catch(() => {})
- // Fetch threads + comment contexts in background so editor highlights are correct from the start
- setTimeout(async () => {
- if (!overleafSock?.projectData) return
-
- // Fetch threads (fast REST call) to know which are resolved
- const threadResult = await overleafFetch(`/project/${projectId}/threads`)
+ // Fetch resolved thread IDs immediately (fast REST call) so editor highlights
+ // don't flash resolved comments while waiting for background fetch
+ overleafFetch(`/project/${projectId}/threads`).then((threadResult) => {
if (threadResult.ok && threadResult.data) {
const threads = threadResult.data as Record<string, { resolved?: boolean }>
const resolvedIds: string[] = []
@@ -697,8 +708,12 @@ You have MCP tools to interact with Overleaf. Use them proactively.
}
sendToRenderer('comments:initThreads', { threads: threadResult.data, resolvedIds })
}
+ }).catch(() => {})
+
+ // Fetch comment contexts from all docs in background (slower — joins each doc)
+ setTimeout(async () => {
+ if (!overleafSock?.projectData) return
- // Fetch comment contexts from all docs
const { docPathMap: dp } = walkRootFolder(overleafSock.projectData.project.rootFolder)
const contexts: Record<string, { file: string; text: string; pos: number }> = {}
for (const [did, rp] of Object.entries(dp)) {
@@ -1159,6 +1174,153 @@ ipcMain.handle('overleaf:socketCompile', async (_e, mainTexRelPath: string) => {
})
})
+// Server-side compile via Overleaf's CLSI
+ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => {
+ if (!overleafSessionCookie || !overleafSock?.projectData) {
+ return { success: false, log: 'Not connected', pdfPath: '' }
+ }
+
+ const projectId = overleafSock.projectData.project._id
+ const effectiveRootDocId = rootDocId || overleafSock.projectData.project.rootDoc_id || null
+
+ // Resolve rootResourcePath (file path of root doc) — matches Overleaf web client
+ let rootResourcePath: string | undefined
+ if (effectiveRootDocId) {
+ const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder)
+ rootResourcePath = docPathMap[effectiveRootDocId]
+ }
+
+ try {
+ sendToRenderer('latex:log', 'Compiling on Overleaf server...\n')
+
+ const compileBody = JSON.stringify({
+ rootDoc_id: effectiveRootDocId,
+ ...(rootResourcePath && { rootResourcePath }),
+ draft: false,
+ check: 'silent',
+ incrementalCompilesEnabled: true,
+ stopOnFirstError: false
+ })
+
+ const compileResult = await overleafFetch(
+ `/project/${projectId}/compile?auto_compile=false`,
+ { method: 'POST', body: compileBody }
+ )
+
+ if (!compileResult.ok) {
+ sendToRenderer('latex:log', `Compile failed: HTTP ${compileResult.status}\n`)
+ return { success: false, log: '', pdfPath: '' }
+ }
+
+ const data = compileResult.data as any
+
+ // Diagnostic: log compile status and available output files
+ const outputPaths = (data.outputFiles || []).map((f: any) => f.path)
+ sendToRenderer('latex:log', `[CLSI status=${data.status}, outputFiles=[${outputPaths.join(', ')}]]\n`)
+
+ // Build query params for fetching output files (matches Overleaf web client)
+ const params = new URLSearchParams()
+ if (data.compileGroup) params.set('compileGroup', data.compileGroup)
+ if (data.clsiServerId) params.set('clsiserverid', data.clsiServerId)
+
+ const buildOutputUrl = (file: { url: string; build?: string }) => {
+ const base = (file.build && data.pdfDownloadDomain)
+ ? `${data.pdfDownloadDomain}${file.url}`
+ : `https://www.overleaf.com${file.url}`
+ return `${base}?${params}`
+ }
+
+ // Build output dir — separate from synced project dir to avoid re-uploading artifacts
+ const syncDir = compilationManager?.dir || join(require('os').tmpdir(), `lattex-${projectId}`)
+ const buildDir = join(syncDir, '.build')
+ await mkdirAsync(buildDir, { recursive: true })
+
+ // Fetch compile log
+ const logFile = (data.outputFiles || []).find((f: any) => f.path === 'output.log')
+ if (logFile) {
+ try {
+ const logContent = await fetchBinary(buildOutputUrl(logFile))
+ sendToRenderer('latex:log', Buffer.from(logContent).toString('utf-8'))
+ } catch (e) {
+ sendToRenderer('latex:log', `[log fetch failed: ${e}]\n`)
+ }
+ }
+
+ // Grab synctex.gz
+ const synctexFile = (data.outputFiles || []).find((f: any) => f.path === 'output.synctex.gz')
+ if (synctexFile) {
+ try {
+ const d = await fetchBinary(buildOutputUrl(synctexFile))
+ await writeFile(join(buildDir, 'output.synctex.gz'), Buffer.from(d))
+ } catch { /* optional */ }
+ }
+
+ // Download PDF — first check outputFiles, then try direct URL from build ID
+ let pdfPath = ''
+ const pdfFile = (data.outputFiles || []).find((f: any) => f.path === 'output.pdf')
+ if (pdfFile) {
+ try {
+ const pdfData = await fetchBinary(buildOutputUrl(pdfFile))
+ const pdfDest = join(buildDir, 'output.pdf')
+ await writeFile(pdfDest, Buffer.from(pdfData))
+ pdfPath = pdfDest
+ } catch (e) {
+ sendToRenderer('latex:log', `\n[PDF download failed: ${e}]\n`)
+ }
+ }
+
+ // If output.pdf not in outputFiles, try constructing URL from another file's build ID
+ // (CLSI may have produced the PDF but not listed it — output.pdfxref proves this)
+ if (!pdfPath && data.outputFiles?.length > 0) {
+ const refFile = data.outputFiles.find((f: any) => f.build)
+ if (refFile) {
+ const pdfUrl = refFile.url.replace(/\/output\/[^/]+$/, '/output/output.pdf')
+ try {
+ const pdfData = await fetchBinary(buildOutputUrl({ url: pdfUrl, build: refFile.build }))
+ if (pdfData.byteLength > 0) {
+ const pdfDest = join(buildDir, 'output.pdf')
+ await writeFile(pdfDest, Buffer.from(pdfData))
+ pdfPath = pdfDest
+ sendToRenderer('latex:log', `\n[PDF retrieved via direct URL (${(pdfData.byteLength / 1024).toFixed(0)} KB)]\n`)
+ }
+ } catch {
+ // PDF truly not available on CLSI
+ }
+ }
+ }
+
+ if (!pdfPath && data.status !== 'success') {
+ sendToRenderer('latex:log', `\n[Compile status: ${data.status} — PDF not available]\n`)
+ }
+
+ return { success: data.status === 'success', log: '', pdfPath }
+ } catch (e) {
+ const msg = `Server compile error: ${e}`
+ sendToRenderer('latex:log', msg + '\n')
+ return { success: false, log: msg, pdfPath: '' }
+ }
+})
+
+/** Fetch a binary resource. Cookie is optional — CDN URLs use build ID for auth. */
+function fetchBinary(url: string, cookie?: string): Promise<ArrayBuffer> {
+ return new Promise((resolve, reject) => {
+ const req = net.request(url)
+ if (cookie) req.setHeader('Cookie', cookie)
+
+ const chunks: Buffer[] = []
+ req.on('response', (res) => {
+ if (res.statusCode && res.statusCode >= 400) {
+ reject(new Error(`HTTP ${res.statusCode}`))
+ return
+ }
+ res.on('data', (chunk) => chunks.push(chunk as Buffer))
+ res.on('end', () => resolve(Buffer.concat(chunks).buffer))
+ })
+ req.on('error', reject)
+ req.end()
+ })
+}
+
/// ── Shell: open external ─────────────────────────────────────────
ipcMain.handle('shell:openExternal', async (_e, url: string) => {
@@ -1169,6 +1331,18 @@ ipcMain.handle('shell:showInFinder', async (_e, path: string) => {
shell.showItemInFolder(path)
})
+ipcMain.handle('shell:savePdf', async (_e, sourcePath: string) => {
+ const { canceled, filePath } = await dialog.showSaveDialog({
+ title: 'Save PDF',
+ defaultPath: basename(sourcePath),
+ filters: [{ name: 'PDF', extensions: ['pdf'] }]
+ })
+ if (canceled || !filePath) return { success: false }
+ const { copyFile } = await import('fs/promises')
+ await copyFile(sourcePath, filePath)
+ return { success: true, path: filePath }
+})
+
// ── App Lifecycle ────────────────────────────────────────────────
app.whenReady().then(async () => {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 2be6e0b..b2984b5 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -128,6 +128,10 @@ const api = {
ipcRenderer.invoke('overleaf:socketCompile', mainTexRelPath) as Promise<{
success: boolean; log: string; pdfPath: string
}>,
+ overleafServerCompile: (rootDocId?: string) =>
+ ipcRenderer.invoke('overleaf:serverCompile', rootDocId) 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) =>
@@ -201,7 +205,9 @@ const api = {
// Shell
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
- showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path)
+ showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path),
+ savePdf: (sourcePath: string) =>
+ ipcRenderer.invoke('shell:savePdf', sourcePath) as Promise<{ success: boolean; path?: string }>
}
contextBridge.exposeInMainWorld('api', api)
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index a6a4003..4c3f731 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -263,6 +263,61 @@ html, body, #root {
50% { opacity: 0.6; }
}
+.compile-btn-group {
+ display: flex;
+ position: relative;
+}
+
+.compile-btn-group .toolbar-btn-primary:first-child {
+ border-radius: var(--radius-sm) 0 0 var(--radius-sm);
+ border-right: 1px solid rgba(255,255,255,0.2);
+}
+
+.compile-dropdown-toggle {
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0 !important;
+ padding: 0 4px !important;
+ min-width: 20px;
+ font-size: 9px;
+}
+
+.compile-dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 4px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ z-index: 100;
+ min-width: 160px;
+ overflow: hidden;
+}
+
+.compile-dropdown-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 6px 12px;
+ border: none;
+ background: none;
+ color: var(--text-primary);
+ font-size: 12px;
+ cursor: pointer;
+ text-align: left;
+}
+
+.compile-dropdown-item:hover {
+ background: var(--bg-hover);
+}
+
+.compile-dropdown-hint {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-left: 12px;
+}
+
.toolbar-main-doc {
font-size: 11px;
color: var(--text-muted);
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index ca2fc1e..6992a17 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -219,15 +219,37 @@ export default function App() {
setStatusMessage('No main document set')
return
}
+ state.setCompiling(true)
+ state.clearCompileLog()
+ setStatusMessage('Compiling on server...')
+
+ const result = await window.api.overleafServerCompile(mainDoc)
+
+ if (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')
+ }
+
+ const handleLocalCompile = async () => {
+ 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...')
+ setStatusMessage('Compiling locally...')
const result = await window.api.overleafSocketCompile(relPath)
- const storeLog = useAppStore.getState().compileLog
- if (!storeLog && result.log) {
+ if (!useAppStore.getState().compileLog && result.log) {
useAppStore.getState().appendCompileLog(result.log)
}
if (result.pdfPath) {
@@ -358,7 +380,7 @@ export default function App() {
<ErrorBoundary>
<ModalProvider />
<div className="app">
- <Toolbar onCompile={handleCompile} onBack={handleBackToProjects} />
+ <Toolbar onCompile={handleCompile} onLocalCompile={handleLocalCompile} onBack={handleBackToProjects} />
<div className="main-content">
<PanelGroup direction="horizontal">
{showFileTree && (
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index 4252464..4edb945 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -335,8 +335,9 @@ export default function Editor() {
}, [activeTab, pathDocMap])
// Sync comment ranges to CodeMirror (exclude resolved threads)
+ // Skip until resolvedThreadIds has been loaded (non-null) to avoid flashing resolved highlights
useEffect(() => {
- if (!viewRef.current || !activeTab) return
+ if (!viewRef.current || !activeTab || resolvedThreadIds === null) return
const ranges: CommentRange[] = []
for (const [threadId, ctx] of Object.entries(commentContexts)) {
if (ctx.file === activeTab && ctx.text && !resolvedThreadIds.has(threadId)) {
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
index 1e1fd7c..c5fe8c4 100644
--- a/src/renderer/src/components/PdfViewer.tsx
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -241,13 +241,32 @@ export default function PdfViewer() {
const result = await window.api.synctexEdit(pdfPath, pageNum, pdfX, pdfY)
if (!result) return
- // Navigate to the source file:line
- try {
- const content = await window.api.readFile(result.file)
- useAppStore.getState().setFileContent(result.file, content)
- useAppStore.getState().openFile(result.file, result.file.split('/').pop() || result.file)
- useAppStore.getState().setPendingGoTo({ file: result.file, line: result.line })
- } catch { /* file not found */ }
+ // Navigate to source — synctex returns relative path (e.g. "latex/main.tex")
+ const store = useAppStore.getState()
+ const relPath = result.file
+
+ // If already loaded in editor, just navigate
+ if (store.fileContents[relPath]) {
+ store.openFile(relPath, relPath.split('/').pop() || relPath)
+ store.setPendingGoTo({ file: relPath, line: result.line })
+ return
+ }
+
+ // Not loaded — join via socket
+ const docId = store.pathDocMap[relPath]
+ if (docId) {
+ try {
+ const joinResult = await window.api.otJoinDoc(docId)
+ if (joinResult.success && joinResult.content !== undefined) {
+ useAppStore.getState().setFileContent(relPath, joinResult.content)
+ if (joinResult.version !== undefined) {
+ useAppStore.getState().setDocVersion(docId, joinResult.version)
+ }
+ useAppStore.getState().openFile(relPath, relPath.split('/').pop() || relPath)
+ useAppStore.getState().setPendingGoTo({ file: relPath, line: result.line })
+ }
+ } catch { /* failed to join doc */ }
+ }
}, [pdfPath])
// Render PDF (with lock to prevent double-render)
@@ -356,6 +375,11 @@ export default function PdfViewer() {
<span className="pdf-scale">{Math.round(scale * 100)}%</span>
<button className="toolbar-btn" onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button>
<button className="toolbar-btn" onClick={() => setScale(1.0)}>Fit</button>
+ {pdfPath && (
+ <button className="toolbar-btn" onClick={() => window.api.savePdf(pdfPath)} title="Download PDF">
+ ↓
+ </button>
+ )}
</>
)}
{tab === 'log' && (
diff --git a/src/renderer/src/components/ReviewPanel.tsx b/src/renderer/src/components/ReviewPanel.tsx
index 7e6c9e5..9396ddf 100644
--- a/src/renderer/src/components/ReviewPanel.tsx
+++ b/src/renderer/src/components/ReviewPanel.tsx
@@ -123,7 +123,7 @@ export default function ReviewPanel() {
}
})
const store = useAppStore.getState()
- store.setResolvedThreadIds(new Set([...store.resolvedThreadIds, threadId]))
+ store.setResolvedThreadIds(new Set([...(store.resolvedThreadIds || []), threadId]))
break
}
case 'reopen-thread': {
@@ -137,7 +137,7 @@ export default function ReviewPanel() {
return { ...prev, [threadId]: t }
})
const store = useAppStore.getState()
- const ids = new Set(store.resolvedThreadIds)
+ const ids = new Set(store.resolvedThreadIds || [])
ids.delete(threadId)
store.setResolvedThreadIds(ids)
break
@@ -154,7 +154,7 @@ export default function ReviewPanel() {
const newCtx = { ...store.commentContexts }
delete newCtx[threadId]
store.setCommentContexts(newCtx)
- const ids = new Set(store.resolvedThreadIds)
+ const ids = new Set(store.resolvedThreadIds || [])
ids.delete(threadId)
store.setResolvedThreadIds(ids)
break
@@ -228,7 +228,7 @@ export default function ReviewPanel() {
return { ...prev, [threadId]: { ...prev[threadId], resolved: true, resolved_at: new Date().toISOString() } }
})
const store = useAppStore.getState()
- store.setResolvedThreadIds(new Set([...store.resolvedThreadIds, threadId]))
+ store.setResolvedThreadIds(new Set([...(store.resolvedThreadIds || []), threadId]))
await window.api.overleafResolveThread(overleafProjectId, threadId, getDocIdForThread(threadId))
}
@@ -244,7 +244,7 @@ export default function ReviewPanel() {
return { ...prev, [threadId]: t }
})
const store = useAppStore.getState()
- const ids = new Set(store.resolvedThreadIds)
+ const ids = new Set(store.resolvedThreadIds || [])
ids.delete(threadId)
store.setResolvedThreadIds(ids)
await window.api.overleafReopenThread(overleafProjectId, threadId, getDocIdForThread(threadId))
@@ -307,7 +307,7 @@ export default function ReviewPanel() {
const newCtx = { ...store.commentContexts }
delete newCtx[threadId]
store.setCommentContexts(newCtx)
- const ids = new Set(store.resolvedThreadIds)
+ const ids = new Set(store.resolvedThreadIds || [])
ids.delete(threadId)
store.setResolvedThreadIds(ids)
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx
index b480ede..94ddb5e 100644
--- a/src/renderer/src/components/Toolbar.tsx
+++ b/src/renderer/src/components/Toolbar.tsx
@@ -1,20 +1,37 @@
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
+import { useState, useRef, useEffect } from 'react'
import { useAppStore } from '../stores/appStore'
interface ToolbarProps {
onCompile: () => void
+ onLocalCompile: () => void
onBack: () => void
}
-export default function Toolbar({ onCompile, onBack }: ToolbarProps) {
+export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarProps) {
const {
compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree,
showReviewPanel, toggleReviewPanel, showChat, toggleChat,
connectionState, overleafProject, onlineUsersCount
} = useAppStore()
+ const [showCompileMenu, setShowCompileMenu] = useState(false)
+ const menuRef = useRef<HTMLDivElement>(null)
+
+ // Close menu on outside click
+ useEffect(() => {
+ if (!showCompileMenu) return
+ const handler = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setShowCompileMenu(false)
+ }
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [showCompileMenu])
+
const projectName = overleafProject?.name || 'Project'
const connectionDot = connectionState === 'connected' ? 'connection-dot-green'
@@ -37,14 +54,36 @@ export default function Toolbar({ onCompile, onBack }: ToolbarProps) {
</span>
</div>
<div className="toolbar-center">
- <button
- className={`toolbar-btn toolbar-btn-primary ${compiling ? 'compiling' : ''}`}
- onClick={onCompile}
- disabled={compiling}
- title="Compile (Cmd+B)"
- >
- {compiling ? 'Compiling...' : 'Compile'}
- </button>
+ <div className="compile-btn-group" ref={menuRef}>
+ <button
+ className={`toolbar-btn toolbar-btn-primary ${compiling ? 'compiling' : ''}`}
+ onClick={onCompile}
+ disabled={compiling}
+ title="Compile on Overleaf server (Cmd+B)"
+ >
+ {compiling ? 'Compiling...' : 'Compile'}
+ </button>
+ <button
+ className="toolbar-btn toolbar-btn-primary compile-dropdown-toggle"
+ onClick={() => setShowCompileMenu(!showCompileMenu)}
+ disabled={compiling}
+ title="Compile options"
+ >
+ ▾
+ </button>
+ {showCompileMenu && (
+ <div className="compile-dropdown-menu">
+ <button className="compile-dropdown-item" onClick={() => { setShowCompileMenu(false); onCompile() }}>
+ Server Compile
+ <span className="compile-dropdown-hint">Cmd+B</span>
+ </button>
+ <button className="compile-dropdown-item" onClick={() => { setShowCompileMenu(false); onLocalCompile() }}>
+ Local Compile
+ <span className="compile-dropdown-hint">latexmk</span>
+ </button>
+ </div>
+ )}
+ </div>
</div>
<div className="toolbar-right">
{onlineUsersCount > 0 && (
diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts
index ff56c28..9a1d441 100644
--- a/src/renderer/src/stores/appStore.ts
+++ b/src/renderer/src/stores/appStore.ts
@@ -109,8 +109,8 @@ interface AppState {
// Comment data
commentContexts: Record<string, CommentContext>
setCommentContexts: (c: Record<string, CommentContext>) => void
- resolvedThreadIds: Set<string>
- setResolvedThreadIds: (ids: Set<string>) => void
+ resolvedThreadIds: Set<string> | null
+ setResolvedThreadIds: (ids: Set<string> | null) => void
overleafDocs: Record<string, string>
setOverleafDocs: (d: Record<string, string>) => void
hoveredThreadId: string | null
@@ -221,7 +221,7 @@ export const useAppStore = create<AppState>((set) => ({
commentContexts: {},
setCommentContexts: (c) => set({ commentContexts: c }),
- resolvedThreadIds: new Set<string>(),
+ resolvedThreadIds: null,
setResolvedThreadIds: (ids) => set({ resolvedThreadIds: ids }),
overleafDocs: {},
setOverleafDocs: (d) => set({ overleafDocs: d }),
@@ -255,7 +255,7 @@ export const useAppStore = create<AppState>((set) => ({
rootFolderId: '',
syncDir: '',
commentContexts: {},
- resolvedThreadIds: new Set<string>(),
+ resolvedThreadIds: null,
overleafDocs: {},
hoveredThreadId: null,
focusedThreadId: null,