diff options
| author | haoyuren <13851610112@163.com> | 2026-03-15 04:00:12 -0500 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-03-15 04:00:12 -0500 |
| commit | 183af193dcf46838506958a50daad61c6b29a23d (patch) | |
| tree | 9814419bb2e6ab1c122979ae42651aa9abaad8ed /src | |
| parent | 7748999a8b0c3ab5e7b107bf7c42f24580cb23aa (diff) | |
Fix server compile: download PDF to .build dir, prevent artifact sync to Overleaf
The root cause of server compile failures was that output.pdf was being
saved into the synced project directory, causing FileSyncBridge to upload
it back to Overleaf as a project file. CLSI then failed because it found
an existing output.pdf blocking its compilation output.
Changes:
- Save compile artifacts (PDF, synctex.gz) to .build/ subdirectory instead
of the synced project root — .build is a dotfile dir ignored by chokidar
- Add pdf/pdfxref/stderr/stdout/chktex to FileSyncBridge ignore patterns
- Add rootResourcePath to compile request body (matches Overleaf web client)
- Implement PDF download with fallback via direct build ID URL construction
- Add server compile handler, compile dropdown menu, PDF save button
- Fix resolved comment highlight flash on startup (null initial state)
- Fix EPIPE crash on startup when stdout/stderr is closed
- Fix synctex inverse search to use relative paths via OT doc join
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/fileSyncBridge.ts | 6 | ||||
| -rw-r--r-- | src/main/index.ts | 192 | ||||
| -rw-r--r-- | src/preload/index.ts | 8 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 55 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 30 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 3 | ||||
| -rw-r--r-- | src/renderer/src/components/PdfViewer.tsx | 38 | ||||
| -rw-r--r-- | src/renderer/src/components/ReviewPanel.tsx | 12 | ||||
| -rw-r--r-- | src/renderer/src/components/Toolbar.tsx | 57 | ||||
| -rw-r--r-- | src/renderer/src/stores/appStore.ts | 8 |
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, |
