From 183af193dcf46838506958a50daad61c6b29a23d Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Sun, 15 Mar 2026 04:00:12 -0500 Subject: Fix server compile: download PDF to .build dir, prevent artifact sync to Overleaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main/fileSyncBridge.ts | 6 +- src/main/index.ts | 192 ++++++++++++++++++++++++++-- src/preload/index.ts | 8 +- src/renderer/src/App.css | 55 ++++++++ src/renderer/src/App.tsx | 30 ++++- src/renderer/src/components/Editor.tsx | 3 +- src/renderer/src/components/PdfViewer.tsx | 38 +++++- src/renderer/src/components/ReviewPanel.tsx | 12 +- src/renderer/src/components/Toolbar.tsx | 57 +++++++-- 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() 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 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 = {} 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 { + 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 @@ -213,6 +213,29 @@ export default function App() { }, [screen]) const handleCompile = async () => { + const state = useAppStore.getState() + const mainDoc = state.mainDocument || state.overleafProject?.rootDocId + if (!mainDoc) { + 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) { @@ -222,12 +245,11 @@ export default function App() { 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() {
- +
{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() { {Math.round(scale * 100)}% + {pdfPath && ( + + )} )} {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(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) {
- +
+ + + {showCompileMenu && ( +
+ + +
+ )} +
{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 setCommentContexts: (c: Record) => void - resolvedThreadIds: Set - setResolvedThreadIds: (ids: Set) => void + resolvedThreadIds: Set | null + setResolvedThreadIds: (ids: Set | null) => void overleafDocs: Record setOverleafDocs: (d: Record) => void hoveredThreadId: string | null @@ -221,7 +221,7 @@ export const useAppStore = create((set) => ({ commentContexts: {}, setCommentContexts: (c) => set({ commentContexts: c }), - resolvedThreadIds: new Set(), + resolvedThreadIds: null, setResolvedThreadIds: (ids) => set({ resolvedThreadIds: ids }), overleafDocs: {}, setOverleafDocs: (d) => set({ overleafDocs: d }), @@ -255,7 +255,7 @@ export const useAppStore = create((set) => ({ rootFolderId: '', syncDir: '', commentContexts: {}, - resolvedThreadIds: new Set(), + resolvedThreadIds: null, overleafDocs: {}, hoveredThreadId: null, focusedThreadId: null, -- cgit v1.2.3