From ebec1a1073f9cc5b69e125d5b284669545ea3d9f Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Wed, 11 Mar 2026 18:16:45 -0500 Subject: Initial commit: ClaudeTeX - LaTeX editor with Overleaf sync Features: - Electron + React + TypeScript app with Cosmic Latte theme - CodeMirror 6 editor with LaTeX syntax highlighting - PDF preview with pdf.js, zoom controls, SyncTeX double-click jump - File tree with context menu (new/rename/delete/reveal) - Overleaf Git clone with token auth + macOS Keychain storage - Git pull/push sync for Overleaf projects - Embedded terminal (xterm.js + node-pty) with Claude CLI integration - LaTeX compilation via latexmk with auto package install detection - Structured compile log (errors/warnings) with click-to-navigate - Main document setting (auto-detect or right-click to set) - Custom modal system (input/confirm/alert) - Resizable panel layout (file tree | editor | PDF + terminal) Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 575 +++++++++++ src/preload/index.d.ts | 7 + src/preload/index.ts | 78 ++ src/renderer/index.html | 12 + src/renderer/src/App.css | 1165 +++++++++++++++++++++++ src/renderer/src/App.tsx | 248 +++++ src/renderer/src/components/Editor.tsx | 188 ++++ src/renderer/src/components/FileTree.tsx | 165 ++++ src/renderer/src/components/ModalProvider.tsx | 123 +++ src/renderer/src/components/OverleafConnect.tsx | 171 ++++ src/renderer/src/components/PdfViewer.tsx | 391 ++++++++ src/renderer/src/components/StatusBar.tsx | 26 + src/renderer/src/components/Terminal.tsx | 165 ++++ src/renderer/src/components/Toolbar.tsx | 75 ++ src/renderer/src/hooks/useModal.ts | 77 ++ src/renderer/src/main.tsx | 10 + src/renderer/src/stores/appStore.ts | 139 +++ 17 files changed, 3615 insertions(+) create mode 100644 src/main/index.ts create mode 100644 src/preload/index.d.ts create mode 100644 src/preload/index.ts create mode 100644 src/renderer/index.html create mode 100644 src/renderer/src/App.css create mode 100644 src/renderer/src/App.tsx create mode 100644 src/renderer/src/components/Editor.tsx create mode 100644 src/renderer/src/components/FileTree.tsx create mode 100644 src/renderer/src/components/ModalProvider.tsx create mode 100644 src/renderer/src/components/OverleafConnect.tsx create mode 100644 src/renderer/src/components/PdfViewer.tsx create mode 100644 src/renderer/src/components/StatusBar.tsx create mode 100644 src/renderer/src/components/Terminal.tsx create mode 100644 src/renderer/src/components/Toolbar.tsx create mode 100644 src/renderer/src/hooks/useModal.ts create mode 100644 src/renderer/src/main.tsx create mode 100644 src/renderer/src/stores/appStore.ts (limited to 'src') diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..0adbe79 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,575 @@ +import { app, BrowserWindow, ipcMain, dialog, shell, net } from 'electron' +import { join, basename, extname, dirname } from 'path' +import { readdir, readFile, writeFile, stat, mkdir, rename, unlink, rm } from 'fs/promises' +import { spawn, type ChildProcess } from 'child_process' +import { watch } from 'chokidar' +import * as pty from 'node-pty' + +let mainWindow: BrowserWindow | null = null +let ptyInstance: pty.IPty | null = null +let fileWatcher: ReturnType | null = null +let compileProcess: ChildProcess | null = null + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 900, + minHeight: 600, + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 15, y: 15 }, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + contextIsolation: true + } + }) + + if (process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } +} + +// ── File System IPC ────────────────────────────────────────────── + +interface FileNode { + name: string + path: string + isDir: boolean + children?: FileNode[] +} + +async function readDirRecursive(dirPath: string, depth = 0): Promise { + if (depth > 5) return [] + const entries = await readdir(dirPath, { withFileTypes: true }) + const nodes: FileNode[] = [] + + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'out') continue + + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + const children = await readDirRecursive(fullPath, depth + 1) + nodes.push({ name: entry.name, path: fullPath, isDir: true, children }) + } else { + const ext = extname(entry.name).toLowerCase() + if (['.tex', '.bib', '.cls', '.sty', '.bst', '.txt', '.md', '.log', '.aux', '.pdf', '.png', '.jpg', '.jpeg', '.svg'].includes(ext)) { + nodes.push({ name: entry.name, path: fullPath, isDir: false }) + } + } + } + + return nodes.sort((a, b) => { + if (a.isDir && !b.isDir) return -1 + if (!a.isDir && b.isDir) return 1 + return a.name.localeCompare(b.name) + }) +} + +ipcMain.handle('dialog:openProject', async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openDirectory'], + title: 'Open LaTeX Project' + }) + if (result.canceled) return null + return result.filePaths[0] +}) + +ipcMain.handle('dialog:selectSaveDir', async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openDirectory', 'createDirectory'], + title: 'Choose where to clone the project' + }) + if (result.canceled) return null + return result.filePaths[0] +}) + +ipcMain.handle('fs:readDir', async (_e, dirPath: string) => { + return readDirRecursive(dirPath) +}) + +ipcMain.handle('fs:readFile', async (_e, filePath: string) => { + return readFile(filePath, 'utf-8') +}) + +// Find the main .tex file (contains \documentclass) in a project +ipcMain.handle('fs:findMainTex', async (_e, dirPath: string) => { + async function search(dir: string, depth: number): Promise { + if (depth > 3) return null + const entries = await readdir(dir, { withFileTypes: true }) + const texFiles: string[] = [] + const dirs: string[] = [] + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'out') continue + const full = join(dir, entry.name) + if (entry.isDirectory()) dirs.push(full) + else if (entry.name.endsWith('.tex')) texFiles.push(full) + } + for (const f of texFiles) { + try { + const content = await readFile(f, 'utf-8') + if (/\\documentclass/.test(content)) return f + } catch { /* skip */ } + } + for (const d of dirs) { + const found = await search(d, depth + 1) + if (found) return found + } + return null + } + return search(dirPath, 0) +}) + +ipcMain.handle('fs:readBinary', async (_e, filePath: string) => { + const buffer = await readFile(filePath) + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) +}) + +ipcMain.handle('fs:writeFile', async (_e, filePath: string, content: string) => { + await writeFile(filePath, content, 'utf-8') +}) + +ipcMain.handle('fs:createFile', async (_e, dirPath: string, fileName: string) => { + const fullPath = join(dirPath, fileName) + await writeFile(fullPath, '', 'utf-8') + return fullPath +}) + +ipcMain.handle('fs:createDir', async (_e, dirPath: string, dirName: string) => { + const fullPath = join(dirPath, dirName) + await mkdir(fullPath, { recursive: true }) + return fullPath +}) + +ipcMain.handle('fs:rename', async (_e, oldPath: string, newPath: string) => { + await rename(oldPath, newPath) +}) + +ipcMain.handle('fs:delete', async (_e, filePath: string) => { + const s = await stat(filePath) + if (s.isDirectory()) { + await rm(filePath, { recursive: true }) + } else { + await unlink(filePath) + } +}) + +ipcMain.handle('fs:stat', async (_e, filePath: string) => { + const s = await stat(filePath) + return { isDir: s.isDirectory(), size: s.size, mtime: s.mtimeMs } +}) + +// ── File Watcher ───────────────────────────────────────────────── + +ipcMain.handle('watcher:start', async (_e, dirPath: string) => { + if (fileWatcher) { + await fileWatcher.close() + } + fileWatcher = watch(dirPath, { + ignored: /(^|[/\\])(\.|node_modules|out|\.aux|\.log|\.fls|\.fdb_latexmk|\.synctex)/, + persistent: true, + depth: 5 + }) + fileWatcher.on('all', (event, path) => { + mainWindow?.webContents.send('watcher:change', { event, path }) + }) +}) + +ipcMain.handle('watcher:stop', async () => { + if (fileWatcher) { + await fileWatcher.close() + fileWatcher = null + } +}) + +// ── LaTeX Compilation ──────────────────────────────────────────── + +// Ensure TeX binaries are in PATH (Electron launched from Finder may miss them) +const texPaths = ['/Library/TeX/texbin', '/usr/local/texlive/2024/bin/universal-darwin', '/usr/texbin', '/opt/homebrew/bin'] +const currentPath = process.env.PATH || '' +for (const p of texPaths) { + if (!currentPath.includes(p)) { + process.env.PATH = `${p}:${process.env.PATH}` + } +} + +// Parse missing packages from compile log +function parseMissingPackages(log: string): string[] { + const missing = new Set() + // Match "File `xxx.sty' not found" + const styRegex = /File `([^']+\.sty)' not found/g + let m: RegExpExecArray | null + while ((m = styRegex.exec(log)) !== null) { + missing.add(m[1].replace(/\.sty$/, '')) + } + // Match "Metric (TFM) file not found" for fonts + const tfmRegex = /Font [^=]+=(\w+) .* not loadable: Metric/g + while ((m = tfmRegex.exec(log)) !== null) { + missing.add(m[1]) + } + return [...missing] +} + +// Find which tlmgr packages provide the missing files +async function findTlmgrPackages(names: string[]): Promise { + const packages = new Set() + for (const name of names) { + const result = await new Promise((resolve) => { + let out = '' + const proc = spawn('tlmgr', ['search', '--file', `${name}.sty`], { env: process.env }) + proc.stdout?.on('data', (d) => { out += d.toString() }) + proc.stderr?.on('data', (d) => { out += d.toString() }) + proc.on('close', () => resolve(out)) + proc.on('error', () => resolve('')) + }) + // tlmgr search output: "package_name:\n texmf-dist/..." + const pkgMatch = result.match(/^(\S+):$/m) + if (pkgMatch) { + packages.add(pkgMatch[1]) + } else { + // Fallback: use the name itself as package name + packages.add(name) + } + } + return [...packages] +} + +ipcMain.handle('latex:compile', async (_e, filePath: string) => { + if (compileProcess) { + compileProcess.kill() + } + + const dir = dirname(filePath) + const file = basename(filePath) + + return new Promise<{ success: boolean; log: string; missingPackages?: string[] }>((resolve) => { + let log = '' + compileProcess = spawn('latexmk', ['-pdf', '-f', '-g', '-bibtex', '-synctex=1', '-interaction=nonstopmode', '-file-line-error', file], { + cwd: dir, + env: process.env + }) + + compileProcess.stdout?.on('data', (data) => { + log += data.toString() + mainWindow?.webContents.send('latex:log', data.toString()) + }) + compileProcess.stderr?.on('data', (data) => { + log += data.toString() + mainWindow?.webContents.send('latex:log', data.toString()) + }) + compileProcess.on('close', async (code) => { + compileProcess = null + if (code !== 0) { + const missing = parseMissingPackages(log) + if (missing.length > 0) { + const packages = await findTlmgrPackages(missing) + resolve({ success: false, log, missingPackages: packages }) + return + } + } + resolve({ success: code === 0, log }) + }) + compileProcess.on('error', (err) => { + compileProcess = null + resolve({ success: false, log: err.message }) + }) + }) +}) + +// Install TeX packages via tlmgr (runs in PTY so sudo can prompt for password) +ipcMain.handle('latex:installPackages', async (_e, packages: string[]) => { + if (!packages.length) return { success: false, message: 'No packages specified' } + + // Try without sudo first + const tryDirect = await new Promise<{ success: boolean; message: string }>((resolve) => { + let out = '' + const proc = spawn('tlmgr', ['install', ...packages], { env: process.env }) + proc.stdout?.on('data', (d) => { out += d.toString() }) + proc.stderr?.on('data', (d) => { out += d.toString() }) + proc.on('close', (code) => resolve({ success: code === 0, message: out })) + proc.on('error', (err) => resolve({ success: false, message: err.message })) + }) + + if (tryDirect.success) return tryDirect + + // Need sudo — run in PTY terminal so user can enter password + return { success: false, message: 'need_sudo', packages } +}) + +ipcMain.handle('latex:getPdfPath', async (_e, texPath: string) => { + return texPath.replace(/\.tex$/, '.pdf') +}) + +// 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 proc = spawn('synctex', ['edit', '-o', `${page}:${x}:${y}:${pdfPath}`], { + env: process.env + }) + let out = '' + proc.stdout?.on('data', (d) => { out += d.toString() }) + proc.stderr?.on('data', (d) => { out += d.toString() }) + proc.on('close', () => { + // Parse output: Input:filename\nLine:123\n... + const fileMatch = out.match(/Input:(.+)/) + const lineMatch = out.match(/Line:(\d+)/) + if (fileMatch && lineMatch) { + resolve({ file: fileMatch[1].trim(), line: parseInt(lineMatch[1]) }) + } else { + console.log('[synctex] no result:', out.slice(0, 200)) + resolve(null) + } + }) + proc.on('error', () => resolve(null)) + }) +}) + +// SyncTeX: source file:line → PDF page + position (forward search) +ipcMain.handle('synctex:viewFromSource', async (_e, texPath: string, line: number, pdfPath: string) => { + return new Promise<{ page: number; x: number; y: number } | null>((resolve) => { + const proc = spawn('synctex', ['view', '-i', `${line}:0:${texPath}`, '-o', pdfPath], { + env: process.env + }) + let out = '' + proc.stdout?.on('data', (d) => { out += d.toString() }) + proc.stderr?.on('data', (d) => { out += d.toString() }) + proc.on('close', () => { + const pageMatch = out.match(/Page:(\d+)/) + const xMatch = out.match(/x:([0-9.]+)/) + const yMatch = out.match(/y:([0-9.]+)/) + if (pageMatch) { + resolve({ + page: parseInt(pageMatch[1]), + x: xMatch ? parseFloat(xMatch[1]) : 0, + y: yMatch ? parseFloat(yMatch[1]) : 0 + }) + } else { + resolve(null) + } + }) + proc.on('error', () => resolve(null)) + }) +}) + +// ── Terminal / PTY ─────────────────────────────────────────────── + +ipcMain.handle('pty:spawn', async (_e, cwd: string) => { + if (ptyInstance) { + ptyInstance.kill() + } + + const shellPath = process.env.SHELL || '/bin/zsh' + ptyInstance = pty.spawn(shellPath, ['-l'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd, + env: process.env as Record + }) + + ptyInstance.onData((data) => { + mainWindow?.webContents.send('pty:data', data) + }) + + ptyInstance.onExit(() => { + mainWindow?.webContents.send('pty:exit') + }) +}) + +ipcMain.handle('pty:write', async (_e, data: string) => { + ptyInstance?.write(data) +}) + +ipcMain.handle('pty:resize', async (_e, cols: number, rows: number) => { + try { + ptyInstance?.resize(cols, rows) + } catch { /* ignore resize errors */ } +}) + +ipcMain.handle('pty:kill', async () => { + ptyInstance?.kill() + ptyInstance = null +}) + +// ── Overleaf / Git Sync ────────────────────────────────────────── + +// Helper: run git with explicit credentials via a temp credential helper script +function gitWithCreds(args: string[], email: string, password: string, cwd?: string): Promise<{ success: boolean; message: string }> { + return new Promise((resolve) => { + // Use inline credential helper that echoes stored creds + const helper = `!f() { echo "username=${email}"; echo "password=${password}"; }; f` + const fullArgs = ['-c', `credential.helper=${helper}`, ...args] + console.log('[git]', args[0], args.slice(1).join(' ').replace(password, '***')) + const proc = spawn('git', fullArgs, { + cwd, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } + }) + let output = '' + proc.stdout?.on('data', (d) => { output += d.toString() }) + proc.stderr?.on('data', (d) => { output += d.toString() }) + proc.on('close', (code) => { + console.log('[git] exit code:', code, 'output:', output.slice(0, 300)) + resolve({ success: code === 0, message: output }) + }) + proc.on('error', (err) => { + console.log('[git] error:', err.message) + resolve({ success: false, message: err.message }) + }) + }) +} + +// Helper: run git with osxkeychain (for after credentials are stored) +function gitSpawn(args: string[], cwd?: string): Promise<{ success: boolean; message: string }> { + return new Promise((resolve) => { + const fullArgs = ['-c', 'credential.helper=osxkeychain', ...args] + const proc = spawn('git', fullArgs, { + cwd, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } + }) + let output = '' + proc.stdout?.on('data', (d) => { output += d.toString() }) + proc.stderr?.on('data', (d) => { output += d.toString() }) + proc.on('close', (code) => { + resolve({ success: code === 0, message: output }) + }) + proc.on('error', (err) => { + resolve({ success: false, message: err.message }) + }) + }) +} + +// Store credentials in macOS Keychain (no verification — that happens in overleaf:cloneWithAuth) +function storeCredentials(email: string, password: string): Promise { + return new Promise((resolve) => { + // Erase old first + const erase = spawn('git', ['credential-osxkeychain', 'erase']) + erase.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`) + erase.stdin?.end() + erase.on('close', () => { + const store = spawn('git', ['credential-osxkeychain', 'store']) + store.stdin?.write(`protocol=https\nhost=git.overleaf.com\nusername=${email}\npassword=${password}\n\n`) + store.stdin?.end() + store.on('close', (code) => resolve(code === 0)) + }) + }) +} + +// Verify credentials + project access using git ls-remote, then clone +// Overleaf git auth: username is always literal "git", password is the token +ipcMain.handle('overleaf:cloneWithAuth', async (_e, projectId: string, dest: string, token: string, remember: boolean) => { + const repoUrl = `https://git.overleaf.com/${projectId}` + console.log('[overleaf:cloneWithAuth] Verifying access to:', projectId) + + // Step 1: ls-remote to verify both auth and project access + // Username must be "git" (not email), password is the olp_ token + const verify = await gitWithCreds(['ls-remote', '--heads', repoUrl], 'git', token) + + if (!verify.success) { + const msg = verify.message + console.log('[overleaf:cloneWithAuth] ls-remote failed:', msg) + if (msg.includes('only supports Git authentication tokens') || msg.includes('token')) { + return { success: false, message: 'need_token', detail: 'Overleaf requires a Git Authentication Token (not your password).\n\n1. Go to Overleaf → Account Settings\n2. Find "Git Integration"\n3. Generate a token and paste it here.' } + } + if (msg.includes('Authentication failed') || msg.includes('401') || msg.includes('403') || msg.includes('could not read')) { + return { success: false, message: 'auth_failed', detail: 'Authentication failed. Make sure you are using a Git Authentication Token, not your Overleaf password.' } + } + if (msg.includes('not found') || msg.includes('does not appear to be a git repository')) { + return { success: false, message: 'not_found', detail: 'Project not found. Check the URL and ensure you have access.' } + } + return { success: false, message: 'error', detail: msg } + } + + console.log('[overleaf:cloneWithAuth] Auth verified. Storing credentials and cloning...') + + // Step 2: Credentials work — store in keychain if requested + if (remember) { + await storeCredentials('git', token) + console.log('[overleaf:cloneWithAuth] Token saved to Keychain') + } + + // Step 3: Clone using keychain credentials + const result = await gitSpawn(['clone', repoUrl, dest]) + if (result.success) { + return { success: true, message: 'ok', detail: '' } + } else { + return { success: false, message: 'clone_failed', detail: result.message } + } +}) + +// Check if credentials exist in Keychain +ipcMain.handle('overleaf:check', async () => { + return new Promise<{ loggedIn: boolean; email: string }>((resolve) => { + const proc = spawn('git', ['credential-osxkeychain', 'get']) + let out = '' + proc.stdout?.on('data', (d) => { out += d.toString() }) + proc.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`) + proc.stdin?.end() + proc.on('close', (code) => { + if (code === 0 && out.includes('username=')) { + const match = out.match(/username=(.+)/) + resolve({ loggedIn: true, email: match?.[1]?.trim() ?? '' }) + } else { + resolve({ loggedIn: false, email: '' }) + } + }) + proc.on('error', () => { + resolve({ loggedIn: false, email: '' }) + }) + }) +}) + +// Remove credentials from Keychain +ipcMain.handle('overleaf:logout', async () => { + return new Promise((resolve) => { + const proc = spawn('git', ['credential-osxkeychain', 'erase']) + proc.stdin?.write(`protocol=https\nhost=git.overleaf.com\n\n`) + proc.stdin?.end() + proc.on('close', () => resolve()) + }) +}) + +// Git operations for existing repos — use osxkeychain +ipcMain.handle('git:pull', async (_e, cwd: string) => { + return gitSpawn(['pull'], cwd) +}) + +ipcMain.handle('git:push', async (_e, cwd: string) => { + const add = await gitSpawn(['add', '-A'], cwd) + if (!add.success) return add + await gitSpawn(['commit', '-m', `Sync from ClaudeTeX ${new Date().toISOString()}`], cwd) + return gitSpawn(['push'], cwd) +}) + +ipcMain.handle('git:status', async (_e, cwd: string) => { + const result = await gitSpawn(['status', '--porcelain'], cwd) + return { isGit: result.success, status: result.message } +}) + +// ── Shell: open external ───────────────────────────────────────── + +ipcMain.handle('shell:openExternal', async (_e, url: string) => { + await shell.openExternal(url) +}) + +ipcMain.handle('shell:showInFinder', async (_e, path: string) => { + shell.showItemInFolder(path) +}) + +// ── App Lifecycle ──────────────────────────────────────────────── + +app.whenReady().then(createWindow) + +app.on('window-all-closed', () => { + ptyInstance?.kill() + fileWatcher?.close() + compileProcess?.kill() + app.quit() +}) + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } +}) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts new file mode 100644 index 0000000..ccfa000 --- /dev/null +++ b/src/preload/index.d.ts @@ -0,0 +1,7 @@ +import type { ElectronAPI } from './index' + +declare global { + interface Window { + api: ElectronAPI + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..ab360c0 --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,78 @@ +import { contextBridge, ipcRenderer } from 'electron' + +const api = { + // File system + openProject: () => ipcRenderer.invoke('dialog:openProject'), + selectSaveDir: () => ipcRenderer.invoke('dialog:selectSaveDir'), + readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path), + readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path), + findMainTex: (dir: string) => ipcRenderer.invoke('fs:findMainTex', dir) as Promise, + readBinary: (path: string) => ipcRenderer.invoke('fs:readBinary', path) as Promise, + writeFile: (path: string, content: string) => ipcRenderer.invoke('fs:writeFile', path, content), + createFile: (dir: string, name: string) => ipcRenderer.invoke('fs:createFile', dir, name), + createDir: (dir: string, name: string) => ipcRenderer.invoke('fs:createDir', dir, name), + renameFile: (oldPath: string, newPath: string) => ipcRenderer.invoke('fs:rename', oldPath, newPath), + deleteFile: (path: string) => ipcRenderer.invoke('fs:delete', path), + fileStat: (path: string) => ipcRenderer.invoke('fs:stat', path), + + // File watcher + watchStart: (path: string) => ipcRenderer.invoke('watcher:start', path), + watchStop: () => ipcRenderer.invoke('watcher:stop'), + onWatchChange: (cb: (data: { event: string; path: string }) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: { event: string; path: string }) => cb(data) + ipcRenderer.on('watcher:change', handler) + return () => ipcRenderer.removeListener('watcher:change', handler) + }, + + // LaTeX + compile: (path: string) => ipcRenderer.invoke('latex:compile', path), + getPdfPath: (texPath: string) => ipcRenderer.invoke('latex:getPdfPath', texPath), + onCompileLog: (cb: (log: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, log: string) => cb(log) + ipcRenderer.on('latex:log', handler) + return () => ipcRenderer.removeListener('latex:log', handler) + }, + + // Terminal + ptySpawn: (cwd: string) => ipcRenderer.invoke('pty:spawn', cwd), + ptyWrite: (data: string) => ipcRenderer.invoke('pty:write', data), + ptyResize: (cols: number, rows: number) => ipcRenderer.invoke('pty:resize', cols, rows), + ptyKill: () => ipcRenderer.invoke('pty:kill'), + onPtyData: (cb: (data: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, data: string) => cb(data) + ipcRenderer.on('pty:data', handler) + return () => ipcRenderer.removeListener('pty:data', handler) + }, + onPtyExit: (cb: () => void) => { + const handler = () => cb() + ipcRenderer.on('pty:exit', handler) + return () => ipcRenderer.removeListener('pty:exit', handler) + }, + + // Overleaf + overleafCloneWithAuth: (projectId: string, dest: string, token: string, remember: boolean) => + ipcRenderer.invoke('overleaf:cloneWithAuth', projectId, dest, token, remember) as Promise<{ success: boolean; message: string; detail: string }>, + overleafCheck: () => ipcRenderer.invoke('overleaf:check') as Promise<{ loggedIn: boolean; email: string }>, + overleafLogout: () => ipcRenderer.invoke('overleaf:logout'), + gitPull: (cwd: string) => ipcRenderer.invoke('git:pull', cwd), + gitPush: (cwd: string) => ipcRenderer.invoke('git:push', cwd), + gitStatus: (cwd: string) => ipcRenderer.invoke('git:status', cwd), + + // SyncTeX + synctexEdit: (pdfPath: string, page: number, x: number, y: number) => + ipcRenderer.invoke('synctex:editFromPdf', pdfPath, page, x, y) as Promise<{ file: string; line: number } | null>, + synctexView: (texPath: string, line: number, pdfPath: string) => + ipcRenderer.invoke('synctex:viewFromSource', texPath, line, pdfPath) as Promise<{ page: number; x: number; y: number } | null>, + + // LaTeX package management + installTexPackages: (packages: string[]) => + ipcRenderer.invoke('latex:installPackages', packages) as Promise<{ success: boolean; message: string; packages?: string[] }>, + + // Shell + openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), + showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path) +} + +contextBridge.exposeInMainWorld('api', api) + +export type ElectronAPI = typeof api diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..1fb4779 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,12 @@ + + + + + + ClaudeTeX + + +
+ + + diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css new file mode 100644 index 0000000..a0fb0f9 --- /dev/null +++ b/src/renderer/src/App.css @@ -0,0 +1,1165 @@ +/* ── Reset & Base ─────────────────────────────────────────────── */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #FFF8E7; + --bg-secondary: #F5EDD6; + --bg-tertiary: #EDE5CE; + --bg-hover: #E8DFCA; + --bg-active: #D4C9A8; + --border: #D6CEBC; + --text-primary: #3B3228; + --text-secondary: #5C5040; + --text-muted: #8C8070; + --accent: #6B5B3E; + --accent-hover: #7D6B4E; + --accent-blue: #4A6FA5; + --danger: #C75643; + --success: #5B8A3C; + --warning: #B8860B; + --font-mono: "SF Mono", "Fira Code", "JetBrains Mono", "Cascadia Code", monospace; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --radius: 6px; + --radius-sm: 4px; + --shadow-sm: 0 1px 3px rgba(59, 50, 40, 0.08); + --shadow-md: 0 4px 12px rgba(59, 50, 40, 0.1); +} + +html, body, #root { + height: 100%; + overflow: hidden; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 13px; + -webkit-font-smoothing: antialiased; +} + +::selection { + background: #B8D4E3; +} + +::-webkit-scrollbar { + width: 7px; + height: 7px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* ── Welcome Screen ──────────────────────────────────────────── */ + +.welcome-screen { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--bg-primary); +} + +.welcome-drag-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 44px; + -webkit-app-region: drag; +} + +.welcome-content { + text-align: center; +} + +.welcome-content h1 { + font-size: 48px; + font-weight: 700; + letter-spacing: -1px; + margin-bottom: 8px; + color: var(--accent); +} + +.welcome-content p { + color: var(--text-muted); + margin-bottom: 32px; + font-size: 15px; +} + +/* ── Buttons ─────────────────────────────────────────────────── */ + +.btn { + display: inline-block; + padding: 8px 20px; + border: none; + border-radius: var(--radius); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + font-family: var(--font-sans); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +.btn-primary { + background: var(--accent); + color: #FFF8E7; +} +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} +.btn-secondary:hover { + background: var(--bg-hover); +} + +.btn-large { + padding: 12px 32px; + font-size: 15px; + margin: 8px; +} + +/* ── App Layout ──────────────────────────────────────────────── */ + +.app { + height: 100%; + display: flex; + flex-direction: column; +} + +.main-content { + flex: 1; + overflow: hidden; +} + +/* ── Resize Handles ──────────────────────────────────────────── */ + +.resize-handle { + background: var(--border); + transition: background 0.15s; +} +.resize-handle:hover, +.resize-handle[data-resize-handle-active] { + background: var(--accent); +} +.resize-handle-h { + width: 1px !important; +} +.resize-handle-v { + height: 1px !important; +} + +/* ── Toolbar ─────────────────────────────────────────────────── */ + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 12px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + -webkit-app-region: drag; + gap: 8px; +} + +.toolbar-left, .toolbar-center, .toolbar-right { + display: flex; + align-items: center; + gap: 6px; + -webkit-app-region: no-drag; +} + +.toolbar-left { + min-width: 160px; +} + +.drag-region { + width: 68px; + -webkit-app-region: drag; +} + +.project-name { + font-weight: 600; + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; +} + +.toolbar-btn { + padding: 4px 12px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + font-family: var(--font-sans); +} +.toolbar-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.toolbar-btn-primary { + background: var(--accent); + color: #FFF8E7; +} +.toolbar-btn-primary:hover { + background: var(--accent-hover); +} +.toolbar-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.toolbar-btn-primary.compiling { + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.toolbar-main-doc { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar-separator { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; +} + +/* ── File Tree ───────────────────────────────────────────────── */ + +.file-tree { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + user-select: none; +} + +.file-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.5px; + text-transform: uppercase; + border-bottom: 1px solid var(--border); +} + +.file-tree-action { + width: 22px; + height: 22px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.file-tree-action:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.file-tree-content { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.file-tree-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + cursor: pointer; + border-radius: 0; + font-size: 13px; + color: var(--text-secondary); + transition: background 0.1s; +} +.file-tree-item:hover { + background: var(--bg-hover); +} +.file-tree-item.active { + background: var(--bg-active); + color: var(--text-primary); +} + +.file-icon { + font-size: 14px; + width: 18px; + text-align: center; + flex-shrink: 0; +} + +.file-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.main-doc-badge { + margin-left: auto; + font-size: 9px; + font-weight: 600; + padding: 1px 5px; + border-radius: 3px; + background: var(--accent); + color: var(--bg-primary); + text-transform: uppercase; + letter-spacing: 0.3px; + flex-shrink: 0; +} + +.file-tree-empty { + padding: 16px; + color: var(--text-muted); + text-align: center; + font-size: 13px; +} + +/* ── Context Menu ────────────────────────────────────────────── */ + +.context-menu { + position: fixed; + z-index: 1000; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px; + min-width: 160px; + box-shadow: var(--shadow-md); +} + +.context-menu-item { + padding: 6px 12px; + font-size: 13px; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-secondary); +} +.context-menu-item:hover { + background: var(--bg-active); + color: var(--text-primary); +} +.context-menu-item.danger { + color: var(--danger); +} +.context-menu-item.danger:hover { + background: var(--danger); + color: white; +} + +.context-menu-separator { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +/* ── Modal ────────────────────────────────────────────────────── */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(59, 50, 40, 0.4); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + -webkit-app-region: no-drag; +} + +.modal-box { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 24px; + min-width: 400px; + max-width: 500px; + box-shadow: 0 16px 48px rgba(59, 50, 40, 0.2); +} + +.modal-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 16px; +} + +.modal-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-mono); + outline: none; + transition: border-color 0.15s; +} +.modal-input:focus { + border-color: var(--accent); +} + +.modal-message { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 4px; + line-height: 1.5; +} + +.modal-message-mono { + font-family: var(--font-mono); + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow-y: auto; + background: var(--bg-secondary); + padding: 8px 12px; + border-radius: var(--radius-sm); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.btn-danger { + background: var(--danger); + color: white; +} +.btn-danger:hover { + opacity: 0.9; +} + +/* ── Overleaf Connect ─────────────────────────────────────────── */ + +.overleaf-dialog { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 12px; + width: 480px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 16px 48px rgba(59, 50, 40, 0.2); +} + +.overleaf-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 0; +} +.overleaf-header h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} +.overleaf-close { + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; +} +.overleaf-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.overleaf-steps { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 24px; +} +.overleaf-step { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} +.overleaf-step.active { + color: var(--accent); + font-weight: 600; +} +.overleaf-step.done { + color: var(--success); +} +.step-num { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; +} +.overleaf-step.active .step-num { + background: var(--accent); + color: #FFF8E7; +} +.overleaf-step.done .step-num { + background: var(--success); + color: white; +} +.step-line { + flex: 1; + height: 1px; + background: var(--border); +} + +.overleaf-body { + padding: 0 24px 24px; +} + +.overleaf-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.overleaf-help { + font-size: 12px; + color: var(--text-muted); + margin-top: 10px; + line-height: 1.6; +} +.overleaf-help ol { + margin: 4px 0; + padding-left: 18px; +} +.overleaf-help li { + margin: 2px 0; +} + +.overleaf-link { + color: var(--accent-blue); + cursor: pointer; + text-decoration: underline; +} +.overleaf-link:hover { + color: var(--accent); +} + +.overleaf-link-btn { + background: none; + border: none; + color: var(--accent-blue); + cursor: pointer; + text-decoration: underline; + font-size: 12px; + font-family: var(--font-sans); + padding: 0; +} +.overleaf-link-btn:hover { + color: var(--accent); +} + +.overleaf-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; + display: flex; + align-items: baseline; + gap: 8px; +} + +.overleaf-saved-hint { + font-size: 11px; + font-weight: 400; + color: var(--text-muted); +} + +.overleaf-auth-status { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--text-secondary); +} + +.overleaf-id-preview { + margin-top: 8px; + font-size: 12px; + color: var(--success); +} +.overleaf-id-preview code { + font-family: var(--font-mono); + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 3px; +} + +.overleaf-note { + margin-top: 12px; + font-size: 11px; + color: var(--text-muted); + padding: 8px 10px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + line-height: 1.5; +} + +.overleaf-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + margin-top: 10px; + cursor: pointer; +} +.overleaf-checkbox input { + accent-color: var(--accent); +} + +.overleaf-error { + margin: 0 24px; + padding: 8px 12px; + background: #FDE8E8; + border: 1px solid #F5C6C6; + border-radius: var(--radius-sm); + color: var(--danger); + font-size: 12px; + white-space: pre-wrap; + line-height: 1.5; +} + +.overleaf-cloning { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 0; + gap: 16px; +} + +.overleaf-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.overleaf-log { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); + text-align: center; + white-space: pre-wrap; +} + +/* ── Editor ──────────────────────────────────────────────────── */ + +.editor-panel { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-primary); +} + +.editor-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); +} + +.editor-empty-content, +.pdf-empty-content { + text-align: center; + color: var(--text-muted); +} + +.editor-empty-content p:first-child, +.pdf-empty-content p:first-child { + font-size: 15px; + margin-bottom: 8px; +} + +.shortcut-hint { + font-size: 12px; + color: var(--text-muted); + opacity: 0.7; +} + +/* ── Tab Bar ─────────────────────────────────────────────────── */ + +.tab-bar { + display: flex; + align-items: center; + height: 36px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + scrollbar-width: none; +} +.tab-bar::-webkit-scrollbar { + display: none; +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + height: 100%; + padding: 0 14px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + border-right: 1px solid var(--border); + white-space: nowrap; + transition: all 0.1s; + position: relative; +} +.tab:hover { + color: var(--text-secondary); + background: var(--bg-hover); +} +.tab.active { + color: var(--text-primary); + background: var(--bg-primary); +} +.tab.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); +} + +.tab-dot { + color: var(--warning); + margin-right: 2px; +} + +.tab-close { + width: 18px; + height: 18px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.1s; +} +.tab:hover .tab-close { + opacity: 1; +} +.tab-close:hover { + background: var(--bg-hover); + color: var(--danger); +} + +.editor-content { + flex: 1; + overflow: hidden; +} +.editor-content .cm-editor { + height: 100%; +} + +/* ── PDF Viewer ──────────────────────────────────────────────── */ + +.pdf-panel { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-secondary); +} + +.pdf-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); +} + +.pdf-toolbar { + display: flex; + align-items: center; + height: 36px; + padding: 0 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + gap: 4px; +} + +.pdf-tab { + padding: 4px 12px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; + font-family: var(--font-sans); +} +.pdf-tab:hover { + color: var(--text-secondary); +} +.pdf-tab.active { + background: var(--bg-hover); + color: var(--text-primary); +} + +.pdf-toolbar-spacer { + flex: 1; +} + +.pdf-scale { + font-size: 11px; + color: var(--text-muted); + min-width: 40px; + text-align: center; +} + +.pdf-container { + flex: 1; + overflow: auto; + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + background: #EDE5CE; +} + +.pdf-page { + box-shadow: var(--shadow-md); + border-radius: 2px; + cursor: crosshair; +} + +.pdf-error { + padding: 16px; + color: var(--danger); + text-align: center; +} + +.compile-log { + flex: 1; + overflow: auto; + padding: 12px; + background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: 8px; +} +.compile-log pre { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + white-space: pre-wrap; + word-break: break-all; +} + +/* Log badges on tab */ +.log-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + padding: 1px 5px; + border-radius: 8px; + margin-left: 4px; + line-height: 1.4; +} +.log-badge-error { + background: var(--danger); + color: white; +} +.log-badge-warning { + background: var(--warning); + color: white; +} + +/* Log filter bar */ +.log-filters { + display: flex; + gap: 2px; +} +.log-filter-btn { + padding: 2px 8px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 11px; + font-family: var(--font-sans); + cursor: pointer; +} +.log-filter-btn:hover { + color: var(--text-secondary); +} +.log-filter-btn.active { + background: var(--bg-hover); + color: var(--text-primary); + font-weight: 500; +} + +/* Log entries */ +.log-entries { + display: flex; + flex-direction: column; + gap: 6px; +} +.log-entry { + padding: 8px 10px; + border-radius: var(--radius); + border-left: 3px solid transparent; +} +.log-entry-error { + background: #FFF0EE; + border-left-color: var(--danger); +} +.log-entry-warning { + background: #FFF8E0; + border-left-color: var(--warning); +} +.log-entry-info { + background: var(--bg-secondary); + border-left-color: var(--accent-blue); +} +.log-entry-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} +.log-level-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.3px; +} +.level-error { + background: var(--danger); + color: white; +} +.level-warning { + background: var(--warning); + color: white; +} +.level-info { + background: var(--accent-blue); + color: white; +} +.log-entry-clickable { + cursor: pointer; + transition: filter 0.1s; +} +.log-entry-clickable:hover { + filter: brightness(0.95); +} +.log-entry-clickable .log-entry-file { + text-decoration: underline; +} +.log-entry-file { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-blue); +} +.log-entry-message { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} +.log-empty { + color: var(--text-muted); + text-align: center; + padding: 40px; +} + +/* Raw log collapsible */ +.log-raw { + margin-top: auto; + border-top: 1px solid var(--border); + padding-top: 8px; +} +.log-raw summary { + cursor: pointer; + font-size: 11px; + color: var(--text-muted); + padding: 4px 0; + user-select: none; +} +.log-raw summary:hover { + color: var(--text-secondary); +} +.log-raw pre { + max-height: 300px; + overflow: auto; + margin-top: 8px; + padding: 8px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +/* ── Terminal ────────────────────────────────────────────────── */ + +.terminal-panel { + height: 100%; + display: flex; + flex-direction: column; + background: #2D2A24; +} + +.terminal-toolbar { + display: flex; + align-items: center; + height: 32px; + padding: 0 8px; + background: #252219; + border-bottom: 1px solid #3D3830; + gap: 4px; +} + +.terminal-toolbar .pdf-tab { + color: #A09880; +} +.terminal-toolbar .pdf-tab:hover { + color: #C8BFA0; +} +.terminal-toolbar .pdf-tab.active { + background: #3D3830; + color: #E8DFC0; +} + +.terminal-content { + flex: 1; + padding: 4px; + overflow: hidden; +} + +.terminal-content .xterm { + height: 100%; +} + +.quick-actions { + display: flex; + gap: 4px; +} + +.quick-action-btn { + font-size: 11px !important; + padding: 2px 8px !important; + background: #3D3830 !important; + color: #A09880 !important; + border: 1px solid #4D4840 !important; +} +.quick-action-btn:hover { + border-color: #8B7D5E !important; + color: #E8DFC0 !important; +} + +/* ── Status Bar ──────────────────────────────────────────────── */ + +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 24px; + padding: 0 12px; + background: var(--accent); + border-top: 1px solid var(--border); + font-size: 11px; + color: #F5EDD6; +} + +.status-left, .status-right { + display: flex; + align-items: center; + gap: 12px; +} + +.status-compiling { + color: var(--warning); + animation: pulse 1s infinite; +} + +.status-git { + color: #E8DFC0; +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx new file mode 100644 index 0000000..a809ffe --- /dev/null +++ b/src/renderer/src/App.tsx @@ -0,0 +1,248 @@ +import { useState, useEffect, useCallback } from 'react' +import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels' +import { useAppStore } from './stores/appStore' +import { showConfirm } from './hooks/useModal' +import ModalProvider from './components/ModalProvider' +import OverleafConnect from './components/OverleafConnect' +import Toolbar from './components/Toolbar' +import FileTree from './components/FileTree' +import Editor from './components/Editor' +import PdfViewer from './components/PdfViewer' +import Terminal from './components/Terminal' +import StatusBar from './components/StatusBar' + +export default function App() { + const { + projectPath, + setProjectPath, + setFiles, + showTerminal, + showFileTree, + setIsGitRepo, + setGitStatus, + setStatusMessage + } = useAppStore() + + const refreshFiles = useCallback(async () => { + if (!projectPath) return + const files = await window.api.readDir(projectPath) + setFiles(files) + }, [projectPath, setFiles]) + + // Load project + useEffect(() => { + if (!projectPath) return + + refreshFiles() + window.api.watchStart(projectPath) + + // Check git status + window.api.gitStatus(projectPath).then(({ isGit, status }) => { + setIsGitRepo(isGit) + setGitStatus(status) + }) + + // Auto-detect main document if not set + if (!useAppStore.getState().mainDocument) { + window.api.findMainTex(projectPath).then((mainTex) => { + if (mainTex) { + useAppStore.getState().setMainDocument(mainTex) + setStatusMessage(`Main document: ${mainTex.split('/').pop()}`) + } + }) + } + + const unsub = window.api.onWatchChange(() => { + refreshFiles() + }) + + return () => { + unsub() + window.api.watchStop() + } + }, [projectPath, refreshFiles, setIsGitRepo, setGitStatus]) + + // Compile log listener + useEffect(() => { + const unsub = window.api.onCompileLog((log) => { + useAppStore.getState().appendCompileLog(log) + }) + return unsub + }, []) + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey) { + if (e.key === 's') { + e.preventDefault() + handleSave() + } + if (e.key === 'b') { + e.preventDefault() + handleCompile() + } + if (e.key === '`') { + e.preventDefault() + useAppStore.getState().toggleTerminal() + } + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + const handleSave = async () => { + const { activeTab, fileContents } = useAppStore.getState() + if (!activeTab || !fileContents[activeTab]) return + await window.api.writeFile(activeTab, fileContents[activeTab]) + useAppStore.getState().markModified(activeTab, false) + setStatusMessage('Saved') + } + + const handleCompile = async () => { + const { activeTab, mainDocument } = useAppStore.getState() + const target = mainDocument || activeTab + if (!target || !target.endsWith('.tex')) return + + useAppStore.getState().setCompiling(true) + useAppStore.getState().clearCompileLog() + setStatusMessage('Compiling...') + + const result = await window.api.compile(target) as { + success: boolean; log: string; missingPackages?: string[] + } + + console.log('[compile] result.success:', result.success, 'log length:', result.log?.length, 'missingPkgs:', result.missingPackages) + + // Ensure compile log is populated (fallback if streaming events missed) + const storeLog = useAppStore.getState().compileLog + console.log('[compile] storeLog length:', storeLog?.length) + if (!storeLog && result.log) { + useAppStore.getState().appendCompileLog(result.log) + } + + // Always try to load PDF BEFORE setting compiling=false + const pdfPath = await window.api.getPdfPath(target) + console.log('[compile] checking pdfPath:', pdfPath) + try { + const s = await window.api.fileStat(pdfPath) + console.log('[compile] PDF exists, size:', s.size) + useAppStore.getState().setPdfPath(pdfPath) + } catch (err) { + console.log('[compile] PDF not found:', err) + } + + // Now signal compilation done + useAppStore.getState().setCompiling(false) + + // Missing packages detected — offer to install + if (result.missingPackages && result.missingPackages.length > 0) { + const pkgs = result.missingPackages + const ok = await showConfirm( + 'Missing LaTeX Packages', + `The following packages are needed:\n\n${pkgs.join(', ')}\n\nInstall them now? (may require your password in terminal)`, + ) + if (ok) { + setStatusMessage(`Installing ${pkgs.join(', ')}...`) + const installResult = await window.api.installTexPackages(pkgs) + if (installResult.success) { + setStatusMessage('Packages installed. Recompiling...') + handleCompile() + return + } else if (installResult.message === 'need_sudo') { + setStatusMessage('Need sudo — installing via terminal...') + useAppStore.getState().showTerminal || useAppStore.getState().toggleTerminal() + await window.api.ptyWrite(`sudo tlmgr install ${pkgs.join(' ')}\n`) + setStatusMessage('Enter your password in terminal, then recompile with Cmd+B') + return + } else { + setStatusMessage('Package install failed') + } + } + } + + if (result.success) { + setStatusMessage('Compiled successfully') + } else { + setStatusMessage('Compilation had errors — check Log tab') + } + } + + const [showOverleaf, setShowOverleaf] = useState(false) + + const handleOpenProject = async () => { + const path = await window.api.openProject() + if (path) setProjectPath(path) + } + + return ( + <> + + {showOverleaf && ( + { + setShowOverleaf(false) + setProjectPath(path) + }} + onCancel={() => setShowOverleaf(false)} + /> + )} + {!projectPath ? ( +
+
+
+

ClaudeTeX

+

LaTeX editor with AI and Overleaf sync

+ + +
+
+ ) : ( +
+ +
+ + {showFileTree && ( + <> + + + + + + )} + + + + + + + + + + + + + + {showTerminal && ( + <> + + + + + + )} + + + +
+ +
+ )} + + ) +} diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx new file mode 100644 index 0000000..30a1e8b --- /dev/null +++ b/src/renderer/src/components/Editor.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, useCallback } from 'react' +import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection } from '@codemirror/view' +import { EditorState } from '@codemirror/state' +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { bracketMatching, foldGutter, indentOnInput, StreamLanguage } from '@codemirror/language' +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete' +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' +import { stex } from '@codemirror/legacy-modes/mode/stex' +import { useAppStore } from '../stores/appStore' + +// Cosmic Latte light theme +const cosmicLatteTheme = EditorView.theme({ + '&': { + height: '100%', + fontSize: '13.5px', + backgroundColor: '#FFF8E7' + }, + '.cm-content': { + caretColor: '#3B3228', + fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace', + color: '#3B3228', + padding: '8px 0' + }, + '.cm-cursor': { borderLeftColor: '#3B3228' }, + '.cm-activeLine': { backgroundColor: '#F5EDD6' }, + '.cm-activeLineGutter': { backgroundColor: '#F5EDD6' }, + '.cm-selectionBackground, ::selection': { backgroundColor: '#B8D4E3 !important' }, + '.cm-gutters': { + backgroundColor: '#F5EDD6', + color: '#A09880', + border: 'none', + borderRight: '1px solid #D6CEBC', + paddingRight: '8px' + }, + '.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' }, + '.cm-foldGutter': { width: '16px' }, + '.cm-matchingBracket': { backgroundColor: '#D4C9A8', outline: 'none' }, + // LaTeX syntax colors — warm earthy palette on Cosmic Latte + '.cm-keyword': { color: '#8B2252' }, // commands: \begin, \section + '.cm-atom': { color: '#B8860B' }, // constants + '.cm-string': { color: '#5B8A3C' }, // strings / text args + '.cm-comment': { color: '#A09880', fontStyle: 'italic' }, // % comments + '.cm-bracket': { color: '#4A6FA5' }, // braces {} + '.cm-tag': { color: '#8B2252' }, // LaTeX tags + '.cm-builtin': { color: '#6B5B3E' }, // builtins + '.ͼ5': { color: '#8B2252' }, // keywords like \begin + '.ͼ6': { color: '#4A6FA5' }, // braces/brackets + '.ͼ7': { color: '#5B8A3C' }, // strings + '.ͼ8': { color: '#A09880' }, // comments +}, { dark: false }) + +export default function Editor() { + const editorRef = useRef(null) + const viewRef = useRef(null) + const { activeTab, fileContents, openTabs, setFileContent, markModified } = useAppStore() + + const pendingGoTo = useAppStore((s) => s.pendingGoTo) + const content = activeTab ? fileContents[activeTab] ?? '' : '' + + // Handle goTo when file is already open (no editor recreation needed) + useEffect(() => { + if (!pendingGoTo || !viewRef.current) return + if (activeTab !== pendingGoTo.file) return + + const view = viewRef.current + const lineNum = Math.min(pendingGoTo.line, view.state.doc.lines) + const lineInfo = view.state.doc.line(lineNum) + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) + }) + view.focus() + useAppStore.getState().setPendingGoTo(null) + }, [pendingGoTo]) + + // Create/update editor + useEffect(() => { + if (!editorRef.current) return + + if (viewRef.current) { + viewRef.current.destroy() + } + + const updateListener = EditorView.updateListener.of((update) => { + if (update.docChanged && activeTab) { + const newContent = update.state.doc.toString() + setFileContent(activeTab, newContent) + markModified(activeTab, true) + } + }) + + const state = EditorState.create({ + doc: content, + extensions: [ + lineNumbers(), + highlightActiveLine(), + highlightActiveLineGutter(), + drawSelection(), + rectangularSelection(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + foldGutter(), + history(), + highlightSelectionMatches(), + StreamLanguage.define(stex), + keymap.of([ + ...defaultKeymap, + ...historyKeymap, + ...closeBracketsKeymap, + ...searchKeymap, + indentWithTab + ]), + cosmicLatteTheme, + updateListener, + EditorView.lineWrapping + ] + }) + + const view = new EditorView({ + state, + parent: editorRef.current + }) + viewRef.current = view + + // Apply pending navigation (from log click) + const goTo = useAppStore.getState().pendingGoTo + if (goTo && goTo.file === activeTab && goTo.line) { + requestAnimationFrame(() => { + const lineNum = Math.min(goTo.line, view.state.doc.lines) + const lineInfo = view.state.doc.line(lineNum) + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) + }) + view.focus() + useAppStore.getState().setPendingGoTo(null) + }) + } + + return () => { + viewRef.current?.destroy() + viewRef.current = null + } + }, [activeTab]) // Re-create when tab changes + + if (!activeTab) { + return ( +
+
+

Open a file to start editing

+

+ Cmd+S Save · Cmd+B Compile · Cmd+` Terminal +

+
+
+ ) + } + + return ( +
+
+ {openTabs.map((tab) => ( +
useAppStore.getState().setActiveTab(tab.path)} + > + + {tab.modified && } + {tab.name} + + +
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx new file mode 100644 index 0000000..3163485 --- /dev/null +++ b/src/renderer/src/components/FileTree.tsx @@ -0,0 +1,165 @@ +import { useState, useCallback } from 'react' +import { useAppStore } from '../stores/appStore' +import { showInput, showConfirm } from '../hooks/useModal' + +interface FileNode { + name: string + path: string + isDir: boolean + children?: FileNode[] +} + +function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) { + const [expanded, setExpanded] = useState(depth < 2) + const { activeTab, mainDocument, openFile, setFileContent, setStatusMessage } = useAppStore() + const isActive = activeTab === node.path + const isMainDoc = mainDocument === node.path + + const handleClick = useCallback(async () => { + if (node.isDir) { + setExpanded(!expanded) + return + } + + const ext = node.name.split('.').pop()?.toLowerCase() + if (ext === 'pdf' || ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'svg') { + if (ext === 'pdf') { + useAppStore.getState().setPdfPath(node.path) + } + return + } + + try { + const content = await window.api.readFile(node.path) + setFileContent(node.path, content) + openFile(node.path, node.name) + } catch { + setStatusMessage('Failed to read file') + } + }, [node, expanded, openFile, setFileContent, setStatusMessage]) + + const ext = node.name.split('.').pop()?.toLowerCase() ?? '' + const icon = node.isDir + ? expanded ? '📂' : '📁' + : ext === 'tex' ? '📄' + : ext === 'bib' ? '📚' + : ext === 'pdf' ? '📕' + : ext === 'png' || ext === 'jpg' ? '🖼️' + : '📝' + + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault() + setContextMenu({ x: e.clientX, y: e.clientY }) + const handler = () => { setContextMenu(null); window.removeEventListener('click', handler) } + window.addEventListener('click', handler) + } + + const handleNewFile = async () => { + setContextMenu(null) + const name = await showInput('New File', 'main.tex') + if (!name) return + const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/')) + await window.api.createFile(dir, name) + } + + const handleNewFolder = async () => { + setContextMenu(null) + const name = await showInput('New Folder', 'figures') + if (!name) return + const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/')) + await window.api.createDir(dir, name) + } + + const handleRename = async () => { + setContextMenu(null) + const newName = await showInput('Rename', node.name, node.name) + if (!newName || newName === node.name) return + const dir = node.path.substring(0, node.path.lastIndexOf('/')) + await window.api.renameFile(node.path, dir + '/' + newName) + } + + const handleDelete = async () => { + setContextMenu(null) + const ok = await showConfirm('Delete', `Delete "${node.name}"?`, true) + if (!ok) return + await window.api.deleteFile(node.path) + } + + const handleSetMainDoc = () => { + setContextMenu(null) + useAppStore.getState().setMainDocument(node.path) + setStatusMessage(`Main document: ${node.name}`) + } + + const handleReveal = () => { + window.api.showInFinder(node.path) + setContextMenu(null) + } + + return ( +
+
+ {icon} + {node.name} + {isMainDoc && main} +
+ {contextMenu && ( +
+ {!node.isDir && ext === 'tex' && ( + <> +
+ {isMainDoc ? '✓ Main Document' : 'Set as Main Document'} +
+
+ + )} +
New File
+
New Folder
+
+
Rename
+
Delete
+
+
Reveal in Finder
+
+ )} + {node.isDir && expanded && node.children?.map((child) => ( + + ))} +
+ ) +} + +export default function FileTree() { + const { files, projectPath } = useAppStore() + + const handleNewFile = async () => { + if (!projectPath) return + const name = await showInput('New File', 'main.tex') + if (!name) return + await window.api.createFile(projectPath, name) + } + + return ( +
+
+ FILES + +
+
+ {files.map((node) => ( + + ))} + {files.length === 0 && ( +
No files found
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/ModalProvider.tsx b/src/renderer/src/components/ModalProvider.tsx new file mode 100644 index 0000000..75572d5 --- /dev/null +++ b/src/renderer/src/components/ModalProvider.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect, useRef } from 'react' +import { useModalStore } from '../hooks/useModal' + +export default function ModalProvider() { + return ( + <> + + + + + ) +} + +function InputModal() { + const { inputOpen, inputTitle, inputPlaceholder, inputDefault, inputResolve } = useModalStore() + const [value, setValue] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + if (inputOpen) { + setValue(inputDefault) + setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, 50) + } + }, [inputOpen, inputDefault]) + + if (!inputOpen) return null + + const close = (result: string | null) => { + useModalStore.setState({ inputOpen: false }) + inputResolve?.(result) + } + + return ( +
close(null)}> +
e.stopPropagation()} + onSubmit={(e) => { e.preventDefault(); if (value.trim()) close(value.trim()) }} + > +
{inputTitle}
+ setValue(e.target.value)} + placeholder={inputPlaceholder} + onKeyDown={(e) => { if (e.key === 'Escape') close(null) }} + /> +
+ + +
+
+
+ ) +} + +function ConfirmModal() { + const { confirmOpen, confirmTitle, confirmMessage, confirmDanger, confirmResolve } = useModalStore() + const btnRef = useRef(null) + + useEffect(() => { + if (confirmOpen) setTimeout(() => btnRef.current?.focus(), 50) + }, [confirmOpen]) + + if (!confirmOpen) return null + + const close = (result: boolean) => { + useModalStore.setState({ confirmOpen: false }) + confirmResolve?.(result) + } + + return ( +
close(false)}> +
e.stopPropagation()}> +
{confirmTitle}
+
{confirmMessage}
+
+ + +
+
+
+ ) +} + +function AlertModal() { + const { alertOpen, alertTitle, alertMessage, alertResolve } = useModalStore() + const btnRef = useRef(null) + + useEffect(() => { + if (alertOpen) setTimeout(() => btnRef.current?.focus(), 50) + }, [alertOpen]) + + if (!alertOpen) return null + + const close = () => { + useModalStore.setState({ alertOpen: false }) + alertResolve?.() + } + + return ( +
+
e.stopPropagation()}> +
{alertTitle}
+
{alertMessage}
+
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/OverleafConnect.tsx b/src/renderer/src/components/OverleafConnect.tsx new file mode 100644 index 0000000..6258643 --- /dev/null +++ b/src/renderer/src/components/OverleafConnect.tsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from 'react' +import { useAppStore } from '../stores/appStore' + +interface Props { + onConnected: (projectPath: string) => void + onCancel: () => void +} + +export default function OverleafConnect({ onConnected, onCancel }: Props) { + const [projectUrl, setProjectUrl] = useState('') + const [token, setToken] = useState('') + const [hasStoredToken, setHasStoredToken] = useState(false) + const [busy, setBusy] = useState(false) + const [busyText, setBusyText] = useState('') + const [rememberMe, setRememberMe] = useState(true) + const [error, setError] = useState('') + const { setStatusMessage } = useAppStore() + + // Check if we already have stored credentials + useEffect(() => { + window.api.overleafCheck().then(({ loggedIn }) => { + if (loggedIn) setHasStoredToken(true) + }) + }, []) + + const extractProjectId = (url: string): string | null => { + const cleaned = url.trim() + if (!cleaned) return null + const patterns = [ + /overleaf\.com\/project\/([a-zA-Z0-9]+)/, + /overleaf\.com\/read\/([a-zA-Z0-9]+)/, + /git\.overleaf\.com\/([a-zA-Z0-9]+)/, + /^([a-zA-Z0-9]{10,})$/, + ] + for (const p of patterns) { + const m = cleaned.match(p) + if (m) return m[1] + } + return null + } + + const projectId = extractProjectId(projectUrl) + + const handleClone = async () => { + if (!projectUrl.trim()) { + setError('Please paste an Overleaf project URL'); return + } + if (!projectId) { + setError('Could not find project ID in this URL.\nExpected: https://www.overleaf.com/project/abc123...'); return + } + if (!token.trim()) { + setError('Please enter your Git Authentication Token'); return + } + + setError('') + setBusy(true) + setBusyText('Choose where to save...') + setStatusMessage('Connecting to Overleaf...') + + const parentDir = await window.api.selectSaveDir() + if (!parentDir) { + setBusy(false) + return + } + const dest = parentDir + '/overleaf-' + projectId + + setBusyText('Verifying token & cloning...') + + const result = await window.api.overleafCloneWithAuth(projectId, dest, token.trim(), rememberMe) + + setBusy(false) + + if (result.success) { + setStatusMessage('Cloned successfully') + onConnected(dest) + } else { + setStatusMessage('Clone failed') + setError(result.detail || 'Unknown error') + } + } + + const handleClearToken = async () => { + await window.api.overleafLogout() + setHasStoredToken(false) + setToken('') + } + + return ( +
+
e.stopPropagation()}> +
+

Clone from Overleaf

+ +
+ + {error &&
{error}
} + + {busy ? ( +
+
+
+
{busyText}
+
+
+ ) : ( +
+ {/* Project URL */} + + { setProjectUrl(e.target.value); setError('') }} + placeholder="https://www.overleaf.com/project/..." + autoFocus + /> +
+ Copy from your browser address bar, or from Overleaf Menu → Sync → Git. +
+ {projectId && ( +
+ Project ID: {projectId} +
+ )} + + {/* Token */} +
+ Git Authentication Token + {hasStoredToken && ( + + (saved in Keychain — ) + + )} +
+ setToken(e.target.value)} + placeholder="olp_..." + onKeyDown={(e) => { if (e.key === 'Enter') handleClone() }} + /> + + +
+ Generate at{' '} + window.api.openExternal('https://www.overleaf.com/user/settings')}> + Overleaf Account Settings + + {' '}→ Git Integration. Requires premium. +
+ +
+ + +
+
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx new file mode 100644 index 0000000..e702f15 --- /dev/null +++ b/src/renderer/src/components/PdfViewer.tsx @@ -0,0 +1,391 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import * as pdfjsLib from 'pdfjs-dist' +import { useAppStore } from '../stores/appStore' + +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.mjs', + import.meta.url +).toString() + +// ── Log parsing (Overleaf-style) ────────────────────────────────── + +interface LogEntry { + level: 'error' | 'warning' | 'info' + message: string + file?: string + line?: number +} + +function parseCompileLog(raw: string): LogEntry[] { + const entries: LogEntry[] = [] + const lines = raw.split('\n') + + for (let i = 0; i < lines.length; i++) { + const ln = lines[i] + + // LaTeX Error: ... + if (/^!/.test(ln) || /LaTeX Error:/.test(ln)) { + let msg = ln.replace(/^!\s*/, '') + // Collect continuation lines + while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].startsWith('l.') && !lines[i + 1].startsWith('!')) { + i++ + if (lines[i].trim()) msg += ' ' + lines[i].trim() + } + // Try to get line number from "l.123" line + let lineNum: number | undefined + if (i + 1 < lines.length && /^l\.(\d+)/.test(lines[i + 1])) { + i++ + lineNum = parseInt(lines[i].match(/^l\.(\d+)/)![1]) + } + entries.push({ level: 'error', message: msg.trim(), line: lineNum }) + continue + } + + // file:line: error pattern (file-line-error mode) + const fileLineErr = ln.match(/^\.\/(.+?):(\d+):\s*(.+)/) + if (fileLineErr) { + const msg = fileLineErr[3] + const isWarning = /warning/i.test(msg) + entries.push({ + level: isWarning ? 'warning' : 'error', + message: msg, + file: fileLineErr[1], + line: parseInt(fileLineErr[2]) + }) + continue + } + + // Package ... Warning: + const pkgWarn = ln.match(/Package (\S+) Warning:\s*(.*)/) + if (pkgWarn) { + let msg = `[${pkgWarn[1]}] ${pkgWarn[2]}` + let warnLine: number | undefined + // Collect continuation lines starting with (pkgname) + while (i + 1 < lines.length && /^\(/.test(lines[i + 1])) { + i++ + const contLine = lines[i] + msg += ' ' + contLine.replace(/^\([^)]*\)\s*/, '').trim() + const lineMatch = contLine.match(/on input line (\d+)/) + if (lineMatch) warnLine = parseInt(lineMatch[1]) + } + // Also check the initial line for "on input line N" + if (!warnLine) { + const lineMatch = msg.match(/on input line (\d+)/) + if (lineMatch) warnLine = parseInt(lineMatch[1]) + } + entries.push({ level: 'warning', message: msg.trim(), line: warnLine }) + continue + } + + // LaTeX Warning: + const latexWarn = ln.match(/LaTeX Warning:\s*(.*)/) + if (latexWarn) { + let msg = latexWarn[1] + while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].match(/^[(!.]/) && lines[i + 1].startsWith(' ')) { + i++ + msg += ' ' + lines[i].trim() + } + const lineMatch = msg.match(/on input line (\d+)/) + entries.push({ level: 'warning', message: msg.trim(), line: lineMatch ? parseInt(lineMatch[1]) : undefined }) + continue + } + + // Overfull / Underfull + const overunder = ln.match(/^(Overfull|Underfull) .* at lines (\d+)--(\d+)/) + if (overunder) { + entries.push({ level: 'warning', message: ln.trim(), line: parseInt(overunder[2]) }) + continue + } + if (/^(Overfull|Underfull)/.test(ln)) { + const paraMatch = ln.match(/in paragraph at lines (\d+)--(\d+)/) + entries.push({ level: 'warning', message: ln.trim(), line: paraMatch ? parseInt(paraMatch[1]) : undefined }) + continue + } + + // Missing file + if (/File .* not found/.test(ln)) { + entries.push({ level: 'error', message: ln.trim() }) + continue + } + } + + // Deduplicate + const seen = new Set() + return entries.filter((e) => { + const key = `${e.level}:${e.message}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +// ── Component ───────────────────────────────────────────────────── + +type LogFilter = 'all' | 'error' | 'warning' + +export default function PdfViewer() { + const { pdfPath, compileLog, compiling } = useAppStore() + const containerRef = useRef(null) + const [scale, setScale] = useState(1.0) + const [numPages, setNumPages] = useState(0) + const [tab, setTab] = useState<'pdf' | 'log'>('pdf') + const [logFilter, setLogFilter] = useState('all') + const [error, setError] = useState(null) + const prevCompilingRef = useRef(false) + const renderingRef = useRef(false) + + // Parse and sort log entries (errors first, then warnings) + const logEntries = compileLog ? parseCompileLog(compileLog) : [] + const levelOrder = { error: 0, warning: 1, info: 2 } + logEntries.sort((a, b) => levelOrder[a.level] - levelOrder[b.level]) + + const errorCount = logEntries.filter((e) => e.level === 'error').length + const warningCount = logEntries.filter((e) => e.level === 'warning').length + + const filteredEntries = logFilter === 'all' + ? logEntries + : logEntries.filter((e) => e.level === logFilter) + + // Navigate to file:line in editor + const handleEntryClick = async (entry: LogEntry) => { + if (!entry.line) return + const { projectPath, mainDocument } = useAppStore.getState() + if (!projectPath) return + + // If no file specified, use the main document + const entryFile = entry.file || (mainDocument ? mainDocument.split('/').pop()! : null) + if (!entryFile) return + + // Try resolving the file path + const candidates = [ + entryFile.startsWith('/') ? entryFile : `${projectPath}/${entryFile}`, + ] + if (mainDocument) { + const dir = mainDocument.substring(0, mainDocument.lastIndexOf('/')) + candidates.push(`${dir}/${entryFile}`) + } + + for (const fullPath of candidates) { + try { + const content = await window.api.readFile(fullPath) + useAppStore.getState().setFileContent(fullPath, content) + useAppStore.getState().openFile(fullPath, fullPath.split('/').pop() || entryFile) + useAppStore.getState().setPendingGoTo({ file: fullPath, line: entry.line! }) + return + } catch { /* try next */ } + } + } + + // Auto-switch tab after compilation finishes + useEffect(() => { + if (prevCompilingRef.current && !compiling) { + if (pdfPath) { + setTab('pdf') + } else if (compileLog) { + setTab('log') + } + } + prevCompilingRef.current = compiling + }, [compiling, pdfPath, compileLog]) + + // Store page viewports for synctex coordinate conversion + const pageViewportsRef = useRef>(new Map()) + + // SyncTeX: double-click PDF → jump to source + const handlePdfDoubleClick = useCallback(async (e: MouseEvent) => { + if (!pdfPath) return + const canvas = (e.target as HTMLElement).closest('canvas.pdf-page') as HTMLCanvasElement | null + if (!canvas) return + + const container = containerRef.current + if (!container) return + + // Determine which page was clicked + const canvases = Array.from(container.querySelectorAll('canvas.pdf-page')) + const pageIndex = canvases.indexOf(canvas) + if (pageIndex < 0) return + const pageNum = pageIndex + 1 + + // Get click position relative to canvas (in CSS pixels) + const rect = canvas.getBoundingClientRect() + const clickX = e.clientX - rect.left + const clickY = e.clientY - rect.top + + // Convert to PDF points (72 DPI coordinate system, origin bottom-left) + const vpInfo = pageViewportsRef.current.get(pageNum) + if (!vpInfo) return + const pdfX = (clickX / rect.width) * vpInfo.width + const pdfY = vpInfo.height - (clickY / rect.height) * vpInfo.height + + 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 */ } + }, [pdfPath]) + + // Render PDF (with lock to prevent double-render) + const renderPdf = useCallback(async () => { + if (!pdfPath || !containerRef.current || tab !== 'pdf') return + if (renderingRef.current) return + renderingRef.current = true + + setError(null) + try { + const arrayBuffer = await window.api.readBinary(pdfPath) + const data = new Uint8Array(arrayBuffer) + const pdf = await pdfjsLib.getDocument({ data }).promise + setNumPages(pdf.numPages) + + const container = containerRef.current + if (!container) { renderingRef.current = false; return } + container.innerHTML = '' + pageViewportsRef.current.clear() + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i) + const viewport = page.getViewport({ scale }) + + const baseViewport = page.getViewport({ scale: 1 }) + pageViewportsRef.current.set(i, { width: baseViewport.width, height: baseViewport.height }) + + const canvas = document.createElement('canvas') + canvas.className = 'pdf-page' + const context = canvas.getContext('2d')! + canvas.width = viewport.width * window.devicePixelRatio + canvas.height = viewport.height * window.devicePixelRatio + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + context.scale(window.devicePixelRatio, window.devicePixelRatio) + container.appendChild(canvas) + await page.render({ canvasContext: context, viewport }).promise + } + } catch (err) { + setError(`Failed to load PDF: ${err}`) + } finally { + renderingRef.current = false + } + }, [pdfPath, scale, tab]) + + // Attach double-click listener to PDF container + useEffect(() => { + const container = containerRef.current + if (!container) return + container.addEventListener('dblclick', handlePdfDoubleClick) + return () => container.removeEventListener('dblclick', handlePdfDoubleClick) + }, [handlePdfDoubleClick]) + + useEffect(() => { + renderPdf() + }, [renderPdf]) + + // Empty state + if (!pdfPath && !compileLog) { + return ( +
+
+

No PDF to display

+

Compile a .tex file with Cmd+B

+
+
+ ) + } + + return ( +
+
+ + +
+ {tab === 'pdf' && ( + <> + + {Math.round(scale * 100)}% + + + + )} + {tab === 'log' && ( +
+ + + +
+ )} +
+ + {/* PDF view — always mounted, hidden when log is shown */} +
+ {error &&
{error}
} +
+ + {/* Log view */} + {tab === 'log' && ( +
+ {filteredEntries.length > 0 ? ( +
+ {filteredEntries.map((entry, i) => ( +
handleEntryClick(entry)} + > +
+ + {entry.level === 'error' ? 'Error' : entry.level === 'warning' ? 'Warning' : 'Info'} + + {entry.file && ( + + {entry.file}{entry.line ? `:${entry.line}` : ''} + + )} +
+
{entry.message}
+
+ ))} +
+ ) : compileLog ? ( +
+
+
+ Info +
+
No errors or warnings found.
+
+
+ ) : ( +
No compile log yet.
+ )} + {/* Raw log toggle */} +
+ Raw log output +
{compileLog}
+
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx new file mode 100644 index 0000000..cd11bdd --- /dev/null +++ b/src/renderer/src/components/StatusBar.tsx @@ -0,0 +1,26 @@ +import { useAppStore } from '../stores/appStore' + +export default function StatusBar() { + const { statusMessage, isGitRepo, gitStatus, activeTab, compiling } = useAppStore() + + const lineInfo = activeTab ? activeTab.split('/').pop() : '' + + return ( +
+
+ {compiling && Compiling} + {statusMessage} +
+
+ {isGitRepo && ( + + Git{gitStatus ? ` (${gitStatus.split('\n').filter(Boolean).length} changes)` : ' (clean)'} + + )} + {lineInfo && {lineInfo}} + UTF-8 + LaTeX +
+
+ ) +} diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx new file mode 100644 index 0000000..f7e306e --- /dev/null +++ b/src/renderer/src/components/Terminal.tsx @@ -0,0 +1,165 @@ +import { useEffect, useRef, useState } from 'react' +import { Terminal as XTerm } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { useAppStore } from '../stores/appStore' + +export default function Terminal() { + const termRef = useRef(null) + const xtermRef = useRef(null) + const fitAddonRef = useRef(null) + const { projectPath } = useAppStore() + const [mode, setMode] = useState<'terminal' | 'claude'>('terminal') + + useEffect(() => { + if (!termRef.current || !projectPath) return + + const xterm = new XTerm({ + theme: { + background: '#2D2A24', + foreground: '#E8DFC0', + cursor: '#FFF8E7', + selectionBackground: '#5C5040', + black: '#2D2A24', + red: '#C75643', + green: '#5B8A3C', + yellow: '#B8860B', + blue: '#4A6FA5', + magenta: '#8B6B8B', + cyan: '#5B8A8A', + white: '#E8DFC0', + brightBlack: '#6B5B3E', + brightRed: '#D46A58', + brightGreen: '#6FA050', + brightYellow: '#D4A020', + brightBlue: '#5E84B8', + brightMagenta: '#A080A0', + brightCyan: '#6FA0A0', + brightWhite: '#FFF8E7' + }, + fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace', + fontSize: 13, + cursorBlink: true, + scrollback: 10000 + }) + + const fitAddon = new FitAddon() + xterm.loadAddon(fitAddon) + xterm.open(termRef.current) + + // Fit after a small delay to ensure container is sized + setTimeout(() => fitAddon.fit(), 100) + + xtermRef.current = xterm + fitAddonRef.current = fitAddon + + // Spawn shell + window.api.ptySpawn(projectPath) + + // Pipe data + const unsubData = window.api.onPtyData((data) => { + xterm.write(data) + }) + + const unsubExit = window.api.onPtyExit(() => { + xterm.writeln('\r\n[Process exited]') + }) + + // Send input + xterm.onData((data) => { + window.api.ptyWrite(data) + }) + + // Handle resize + const resizeObserver = new ResizeObserver(() => { + try { + fitAddon.fit() + window.api.ptyResize(xterm.cols, xterm.rows) + } catch { /* ignore */ } + }) + resizeObserver.observe(termRef.current) + + return () => { + resizeObserver.disconnect() + unsubData() + unsubExit() + window.api.ptyKill() + xterm.dispose() + } + }, [projectPath]) + + const launchClaude = () => { + if (!xtermRef.current) return + window.api.ptyWrite('claude\n') + setMode('claude') + } + + const sendToClaude = (prompt: string) => { + if (!xtermRef.current) return + window.api.ptyWrite(prompt + '\n') + } + + return ( +
+
+ + +
+ +
+
+
+ ) +} + +function QuickActions({ onSend }: { onSend: (cmd: string) => void }) { + const { activeTab, fileContents } = useAppStore() + + const actions = [ + { + label: 'Fix Errors', + action: () => { + const log = useAppStore.getState().compileLog + if (log) { + onSend(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`) + } + } + }, + { + label: 'Review', + action: () => { + if (activeTab && fileContents[activeTab]) { + onSend(`Review this LaTeX file for issues and improvements: ${activeTab}`) + } + } + }, + { + label: 'Explain', + action: () => { + if (activeTab) { + onSend(`Explain the structure and content of: ${activeTab}`) + } + } + } + ] + + return ( +
+ {actions.map((a) => ( + + ))} +
+ ) +} diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx new file mode 100644 index 0000000..ac875bd --- /dev/null +++ b/src/renderer/src/components/Toolbar.tsx @@ -0,0 +1,75 @@ +import { useAppStore } from '../stores/appStore' + +interface ToolbarProps { + onCompile: () => void + onSave: () => void + onOpenProject: () => void +} + +export default function Toolbar({ onCompile, onSave, onOpenProject }: ToolbarProps) { + const { projectPath, compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree, isGitRepo, mainDocument } = useAppStore() + const projectName = projectPath?.split('/').pop() ?? 'ClaudeTeX' + + const handlePull = async () => { + if (!projectPath) return + useAppStore.getState().setStatusMessage('Pulling from Overleaf...') + const result = await window.api.gitPull(projectPath) + useAppStore.getState().setStatusMessage(result.success ? 'Pull complete' : 'Pull failed') + } + + const handlePush = async () => { + if (!projectPath) return + useAppStore.getState().setStatusMessage('Pushing to Overleaf...') + const result = await window.api.gitPush(projectPath) + useAppStore.getState().setStatusMessage(result.success ? 'Push complete' : 'Push failed') + } + + return ( +
+
+
+ + {projectName} +
+
+ + + + {mainDocument && ( + + {mainDocument.split('/').pop()} + + )} + {isGitRepo && ( + <> +
+ + + + )} +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/hooks/useModal.ts b/src/renderer/src/hooks/useModal.ts new file mode 100644 index 0000000..fdf6eb3 --- /dev/null +++ b/src/renderer/src/hooks/useModal.ts @@ -0,0 +1,77 @@ +import { create } from 'zustand' + +interface ModalState { + // Input modal + inputOpen: boolean + inputTitle: string + inputPlaceholder: string + inputDefault: string + inputResolve: ((value: string | null) => void) | null + + // Confirm modal + confirmOpen: boolean + confirmTitle: string + confirmMessage: string + confirmDanger: boolean + confirmResolve: ((ok: boolean) => void) | null + + // Alert modal + alertOpen: boolean + alertTitle: string + alertMessage: string + alertResolve: (() => void) | null +} + +export const useModalStore = create(() => ({ + inputOpen: false, + inputTitle: '', + inputPlaceholder: '', + inputDefault: '', + inputResolve: null, + + confirmOpen: false, + confirmTitle: '', + confirmMessage: '', + confirmDanger: false, + confirmResolve: null, + + alertOpen: false, + alertTitle: '', + alertMessage: '', + alertResolve: null, +})) + +export function showInput(title: string, placeholder = '', defaultValue = ''): Promise { + return new Promise((resolve) => { + useModalStore.setState({ + inputOpen: true, + inputTitle: title, + inputPlaceholder: placeholder, + inputDefault: defaultValue, + inputResolve: resolve, + }) + }) +} + +export function showConfirm(title: string, message: string, danger = false): Promise { + return new Promise((resolve) => { + useModalStore.setState({ + confirmOpen: true, + confirmTitle: title, + confirmMessage: message, + confirmDanger: danger, + confirmResolve: resolve, + }) + }) +} + +export function showAlert(title: string, message: string): Promise { + return new Promise((resolve) => { + useModalStore.setState({ + alertOpen: true, + alertTitle: title, + alertMessage: message, + alertResolve: resolve, + }) + }) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx new file mode 100644 index 0000000..136bef2 --- /dev/null +++ b/src/renderer/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './App.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts new file mode 100644 index 0000000..6476239 --- /dev/null +++ b/src/renderer/src/stores/appStore.ts @@ -0,0 +1,139 @@ +import { create } from 'zustand' + +interface FileNode { + name: string + path: string + isDir: boolean + children?: FileNode[] +} + +interface OpenTab { + path: string + name: string + modified: boolean +} + +interface AppState { + // Project + projectPath: string | null + setProjectPath: (p: string | null) => void + + // File tree + files: FileNode[] + setFiles: (f: FileNode[]) => void + + // Editor tabs + openTabs: OpenTab[] + activeTab: string | null + openFile: (path: string, name: string) => void + closeTab: (path: string) => void + setActiveTab: (path: string) => void + markModified: (path: string, modified: boolean) => void + + // Editor content cache + fileContents: Record + setFileContent: (path: string, content: string) => void + + // Main document + mainDocument: string | null + setMainDocument: (p: string | null) => void + + // PDF + pdfPath: string | null + setPdfPath: (p: string | null) => void + + // Compile + compiling: boolean + setCompiling: (c: boolean) => void + compileLog: string + appendCompileLog: (log: string) => void + clearCompileLog: () => void + + // Panels + showTerminal: boolean + toggleTerminal: () => void + showFileTree: boolean + toggleFileTree: () => void + + // Git/Overleaf + isGitRepo: boolean + setIsGitRepo: (v: boolean) => void + gitStatus: string + setGitStatus: (s: string) => void + + // Navigation (from log click → editor) + pendingGoTo: { file: string; line: number } | null + setPendingGoTo: (g: { file: string; line: number } | null) => void + + // Status + statusMessage: string + setStatusMessage: (m: string) => void +} + +export const useAppStore = create((set) => ({ + projectPath: null, + setProjectPath: (p) => set({ projectPath: p }), + + files: [], + setFiles: (f) => set({ files: f }), + + openTabs: [], + activeTab: null, + openFile: (path, name) => + set((s) => { + const exists = s.openTabs.find((t) => t.path === path) + if (exists) return { activeTab: path } + return { + openTabs: [...s.openTabs, { path, name, modified: false }], + activeTab: path + } + }), + closeTab: (path) => + set((s) => { + const tabs = s.openTabs.filter((t) => t.path !== path) + const newContents = { ...s.fileContents } + delete newContents[path] + return { + openTabs: tabs, + activeTab: s.activeTab === path ? (tabs[tabs.length - 1]?.path ?? null) : s.activeTab, + fileContents: newContents + } + }), + setActiveTab: (path) => set({ activeTab: path }), + markModified: (path, modified) => + set((s) => ({ + openTabs: s.openTabs.map((t) => (t.path === path ? { ...t, modified } : t)) + })), + + fileContents: {}, + setFileContent: (path, content) => + set((s) => ({ fileContents: { ...s.fileContents, [path]: content } })), + + mainDocument: null, + setMainDocument: (p) => set({ mainDocument: p }), + + pdfPath: null, + setPdfPath: (p) => set({ pdfPath: p }), + + compiling: false, + setCompiling: (c) => set({ compiling: c }), + compileLog: '', + appendCompileLog: (log) => set((s) => ({ compileLog: s.compileLog + log })), + clearCompileLog: () => set({ compileLog: '' }), + + showTerminal: true, + toggleTerminal: () => set((s) => ({ showTerminal: !s.showTerminal })), + showFileTree: true, + toggleFileTree: () => set((s) => ({ showFileTree: !s.showFileTree })), + + isGitRepo: false, + setIsGitRepo: (v) => set({ isGitRepo: v }), + gitStatus: '', + setGitStatus: (s) => set({ gitStatus: s }), + + pendingGoTo: null, + setPendingGoTo: (g) => set({ pendingGoTo: g }), + + statusMessage: 'Ready', + setStatusMessage: (m) => set({ statusMessage: m }) +})) -- cgit v1.2.3