From d1ee677591bd5e6e8b1726b2281621adf6131332 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Sun, 15 Mar 2026 04:27:43 -0500 Subject: v0.3.0: Fix sync exclusions, add editor zoom, cached PDF loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exclude CLAUDE.md from sync (move to .claude/ dotfile dir, clean up root copy) - Add Ctrl+wheel font zoom for editor (capture phase, disable Electron built-in zoom) - Load cached PDF on project connect (avoid recompile to see last PDF) - Add synctex debug logging for PDF↔source navigation troubleshooting - Fix .claude/ dir creation order (mkdir before write) Co-Authored-By: Claude Opus 4.6 --- src/main/fileSyncBridge.ts | 13 ++++-- src/main/index.ts | 72 +++++++++++++++++++++-------- src/preload/index.ts | 10 +++- src/renderer/src/components/Editor.tsx | 24 ++++++++++ src/renderer/src/components/PdfViewer.tsx | 10 ++-- src/renderer/src/components/ProjectList.tsx | 1 + 6 files changed, 102 insertions(+), 28 deletions(-) (limited to 'src') diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts index fb45208..7d7274a 100644 --- a/src/main/fileSyncBridge.ts +++ b/src/main/fileSyncBridge.ts @@ -138,7 +138,8 @@ export class FileSyncBridge { atomic: true, ignored: [ /(^|[/\\])\../, // dotfiles - /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/ // LaTeX output files + /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/, // LaTeX output files + /(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/ // App-generated config files ] }) @@ -449,6 +450,10 @@ export class FileSyncBridge { private onFileChanged(relPath: string): void { if (this.stopped) return + // Skip app-generated config files that should not be synced to Overleaf + const basename = relPath.split('/').pop() || relPath + if (basename === 'CLAUDE.md' || basename === '.mcp.json') return + // Layer 1: Skip if bridge is currently writing this file if (this.writesInProgress.has(relPath)) { bridgeLog(`[FileSyncBridge] skipping ${relPath} (write in progress)`) @@ -831,9 +836,10 @@ export class FileSyncBridge { for (const relPath of allFiles) { if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) continue - // Skip LaTeX output files + // Skip LaTeX output files and app-generated config files 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 + if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) continue bridgeLog(`[FileSyncBridge] orphaned file found: ${relPath}`) this.onNewLocalFile(relPath) @@ -850,9 +856,10 @@ export class FileSyncBridge { if (this.stopped) return if (this.writesInProgress.has(relPath)) return - // Skip LaTeX output files and dotfiles (same as chokidar ignored) + // Skip LaTeX output files, dotfiles, and app-generated config files (same as chokidar ignored) 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 + if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) return // Debounce 1s to let the tool finish writing const key = 'new:' + relPath diff --git a/src/main/index.ts b/src/main/index.ts index 2be5ab8..fa5c4b1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -53,6 +53,9 @@ function createWindow(): void { } }) + // Disable Electron's built-in pinch/Ctrl+wheel zoom so editor can handle it + mainWindow.webContents.setVisualZoomLevelLimits(1, 1) + if (process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -92,33 +95,44 @@ for (const p of texPaths) { 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('/')) + console.log(`[synctex] edit -o ${page}:${x}:${y}:${pdfPath} (cwd: ${pdfDir})`) const proc = spawn('synctex', ['edit', '-o', `${page}:${x}:${y}:${pdfPath}`], { env: process.env, cwd: pdfDir }) - let out = '' - proc.stdout?.on('data', (d) => { out += d.toString() }) - proc.stderr?.on('data', (d) => { out += d.toString() }) - proc.on('close', () => { + let stdout = '' + let stderr = '' + proc.stdout?.on('data', (d) => { stdout += d.toString() }) + proc.stderr?.on('data', (d) => { stderr += d.toString() }) + proc.on('close', (code) => { + console.log(`[synctex] exit=${code} stdout=${stdout.slice(0, 300)} stderr=${stderr.slice(0, 200)}`) // Parse output: Input:filename\nLine:123\n... - const fileMatch = out.match(/Input:(.+)/) - const lineMatch = out.match(/Line:(\d+)/) + const fileMatch = stdout.match(/Input:(.+)/) + const lineMatch = stdout.match(/Line:(\d+)/) if (fileMatch && lineMatch) { let filePath = fileMatch[1].trim() - // Convert absolute path to relative (strip tmpDir prefix) + // Strip CLSI compilation prefix (server compile uses /compile/ as cwd) + if (filePath.startsWith('/compile/')) { + filePath = filePath.slice('/compile/'.length) + } + // Convert absolute path to relative (strip tmpDir prefix for local compile) const syncDir = compilationManager?.dir if (syncDir && filePath.startsWith(syncDir)) { filePath = filePath.slice(syncDir.length).replace(/^\//, '') } // Strip leading ./ if (filePath.startsWith('./')) filePath = filePath.slice(2) + console.log(`[synctex] resolved: file=${filePath} line=${lineMatch[1]}`) resolve({ file: filePath, line: parseInt(lineMatch[1]) }) } else { - console.log('[synctex] no result:', out.slice(0, 200)) + console.log('[synctex] no match in output') resolve(null) } }) - proc.on('error', () => resolve(null)) + proc.on('error', (err) => { + console.log(`[synctex] spawn error: ${err.message}`) + resolve(null) + }) }) }) @@ -648,8 +662,11 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { } } }, null, 2)).catch(() => {}) - // Write CLAUDE.md with project context - writeFile(join(tmpDir, 'CLAUDE.md'), `# LatteX Project — Overleaf Integration + // Clean up old root-level CLAUDE.md (was incorrectly placed there before) + require('fs').unlink(join(tmpDir, 'CLAUDE.md'), () => {}) + // Write .claude/ dir with CLAUDE.md + settings (dotfile dir = excluded from sync) + mkdirAsync(join(tmpDir, '.claude'), { recursive: true }).then(async () => { + await writeFile(join(tmpDir, '.claude', 'CLAUDE.md'), `# LatteX Project — Overleaf Integration This is a LaTeX project synced from Overleaf via LatteX. Files here are bidirectionally synced — edits you make will appear on Overleaf. @@ -690,10 +707,8 @@ You have MCP tools to interact with Overleaf. Use them proactively. 2. Use \`compile_latex\` to compile 3. If errors: use \`get_compile_errors\` for details, fix them, recompile 4. If warnings: use \`get_compile_warnings\` to review -`).catch(() => {}) - // Write .claude/settings.json to auto-allow MCP tools - mkdirAsync(join(tmpDir, '.claude'), { recursive: true }).then(() => - writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ +`) + await writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: [ 'mcp__lattex__get_comments', @@ -711,7 +726,7 @@ You have MCP tools to interact with Overleaf. Use them proactively. ] } }, null, 2)) - ).catch(() => {}) + }).catch(() => {}) // Fetch resolved thread IDs immediately (fast REST call) so editor highlights // don't flash resolved comments while waiting for background fetch @@ -749,6 +764,15 @@ You have MCP tools to interact with Overleaf. Use them proactively. sendToRenderer('comments:initContexts', { contexts }) }, 3000) + // Check for cached PDF from previous compile + const buildDir = join(tmpDir, '.build') + const cachedPdf = join(buildDir, 'output.pdf') + let cachedPdfPath: string | undefined + try { + const stat = await require('fs').promises.stat(cachedPdf) + if (stat.size > 0) cachedPdfPath = cachedPdf + } catch { /* no cached PDF */ } + return { success: true, files, @@ -760,7 +784,8 @@ You have MCP tools to interact with Overleaf. Use them proactively. pathDocMap, fileRefs, rootFolderId, - syncDir: tmpDir + syncDir: tmpDir, + cachedPdfPath } } catch (e) { console.log('[ot:connect] error:', e) @@ -1262,13 +1287,20 @@ ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => { } } - // Grab synctex.gz + // Grab synctex.gz (needed for PDF↔source navigation) const synctexFile = (data.outputFiles || []).find((f: any) => f.path === 'output.synctex.gz') if (synctexFile) { try { - const d = await fetchBinary(buildOutputUrl(synctexFile)) + const synctexUrl = buildOutputUrl(synctexFile) + console.log(`[compile] downloading synctex.gz from ${synctexUrl.slice(0, 80)}...`) + const d = await fetchBinary(synctexUrl) await writeFile(join(buildDir, 'output.synctex.gz'), Buffer.from(d)) - } catch { /* optional */ } + console.log(`[compile] synctex.gz saved (${d.byteLength} bytes)`) + } catch (e) { + console.log(`[compile] synctex.gz download failed: ${e}`) + } + } else { + console.log('[compile] no synctex.gz in compile output') } // Download PDF — first check outputFiles, then try direct URL from build ID diff --git a/src/preload/index.ts b/src/preload/index.ts index b2984b5..649c3aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,9 +1,15 @@ // Copyright (c) 2026 Yuren Hao // Licensed under AGPL-3.0 - see LICENSE file -import { contextBridge, ipcRenderer, webUtils } from 'electron' +import { contextBridge, ipcRenderer, webUtils, webFrame } from 'electron' import { createHash } from 'crypto' +// Prevent Electron's built-in Ctrl+wheel zoom so editor can handle font scaling +webFrame.setVisualZoomLevelLimits(1, 1) +window.addEventListener('wheel', (e) => { + if (e.ctrlKey || e.metaKey) e.preventDefault() +}, { passive: false }) + const api = { // File system readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path), @@ -66,6 +72,8 @@ const api = { pathDocMap?: Record fileRefs?: Array<{ id: string; path: string }> rootFolderId?: string + syncDir?: string + cachedPdfPath?: string message?: string }>, otDisconnect: () => ipcRenderer.invoke('ot:disconnect'), diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index 4edb945..8319e93 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -88,6 +88,7 @@ export default function Editor() { const docSyncRef = useRef(null) const cursorThrottleRef = useRef | null>(null) + const [editorFontSize, setEditorFontSize] = useState(13.5) // Add comment state const [newComment, setNewComment] = useState<{ from: number; to: number; text: string } | null>(null) @@ -358,6 +359,29 @@ export default function Editor() { viewRef.current.dispatch({ effects: highlightThreadEffect.of(hoveredThreadId) }) }, [hoveredThreadId]) + // Ctrl+wheel / pinch zoom on editor (capture phase to beat CodeMirror) + useEffect(() => { + const el = editorRef.current + if (!el) return + const handleWheel = (e: WheelEvent) => { + if (!(e.ctrlKey || e.metaKey)) return + e.preventDefault() + e.stopPropagation() + const delta = e.deltaY > 0 ? -1 : 1 + setEditorFontSize((s) => Math.min(28, Math.max(8, +(s + delta * 0.5).toFixed(1)))) + } + el.addEventListener('wheel', handleWheel, { passive: false, capture: true }) + return () => el.removeEventListener('wheel', handleWheel, { capture: true }) + }, []) + + // Apply font size to editor + useEffect(() => { + if (!viewRef.current) return + const wrapper = viewRef.current.dom + wrapper.style.fontSize = `${editorFontSize}px` + viewRef.current.requestMeasure() + }, [editorFontSize]) + if (!activeTab) { return (
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx index c5fe8c4..01896d7 100644 --- a/src/renderer/src/components/PdfViewer.tsx +++ b/src/renderer/src/components/PdfViewer.tsx @@ -214,9 +214,9 @@ export default function PdfViewer() { // SyncTeX: double-click PDF → jump to source const handlePdfDoubleClick = useCallback(async (e: MouseEvent) => { - if (!pdfPath) return + if (!pdfPath) { console.log('[synctex-ui] no pdfPath'); return } const canvas = (e.target as HTMLElement).closest('canvas.pdf-page') as HTMLCanvasElement | null - if (!canvas) return + if (!canvas) { console.log('[synctex-ui] no canvas target'); return } const container = containerRef.current if (!container) return @@ -234,12 +234,14 @@ export default function PdfViewer() { // Convert to PDF points (72 DPI coordinate system, origin bottom-left) const vpInfo = pageViewportsRef.current.get(pageNum) - if (!vpInfo) return + if (!vpInfo) { console.log('[synctex-ui] no viewport info for page', pageNum); return } const pdfX = (clickX / rect.width) * vpInfo.width const pdfY = vpInfo.height - (clickY / rect.height) * vpInfo.height + console.log(`[synctex-ui] dblclick page=${pageNum} pdfX=${pdfX.toFixed(1)} pdfY=${pdfY.toFixed(1)} path=${pdfPath}`) const result = await window.api.synctexEdit(pdfPath, pageNum, pdfX, pdfY) - if (!result) return + if (!result) { console.log('[synctex-ui] synctex returned null'); return } + console.log(`[synctex-ui] result: file=${result.file} line=${result.line}`) // Navigate to source — synctex returns relative path (e.g. "latex/main.tex") const store = useAppStore.getState() diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx index 58a6bb0..2fe23de 100644 --- a/src/renderer/src/components/ProjectList.tsx +++ b/src/renderer/src/components/ProjectList.tsx @@ -69,6 +69,7 @@ export default function ProjectList({ onOpenProject }: Props) { store.setOverleafProjectId(pid) store.setConnectionState('connected') if (result.syncDir) store.setSyncDir(result.syncDir) + if (result.cachedPdfPath) store.setPdfPath(result.cachedPdfPath) setStatusMessage('Connected') onOpenProject(pid) } else { -- cgit v1.2.3