summaryrefslogtreecommitdiff
path: root/src/renderer
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-15 04:00:12 -0500
committerhaoyuren <13851610112@163.com>2026-03-15 04:00:12 -0500
commit183af193dcf46838506958a50daad61c6b29a23d (patch)
tree9814419bb2e6ab1c122979ae42651aa9abaad8ed /src/renderer
parent7748999a8b0c3ab5e7b107bf7c42f24580cb23aa (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.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
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,