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/renderer | |
| 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/renderer')
| -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 |
7 files changed, 172 insertions, 31 deletions
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, |
