diff --git a/apps/desktop/src-tauri/src/history.rs b/apps/desktop/src-tauri/src/history.rs index 3a7cfe9..a1e9b34 100644 --- a/apps/desktop/src-tauri/src/history.rs +++ b/apps/desktop/src-tauri/src/history.rs @@ -62,8 +62,7 @@ fn ensure_excludes(project_root: &str, repo: &Repository) { let excludes_path = Path::new(project_root) .join(".claudeprism") .join("history-exclude"); - if !excludes_path.exists() { - let content = r#"# LaTeX build artifacts + let content = r#"# LaTeX build artifacts *.aux *.log *.out @@ -93,8 +92,17 @@ Thumbs.db # ClaudePrism internal .claudeprism/ +.prism/ "#; + if !excludes_path.exists() { let _ = fs::write(&excludes_path, content); + } else { + // Migrate: add .prism/ if missing from existing excludes file + if let Ok(existing) = fs::read_to_string(&excludes_path) { + if !existing.contains(".prism/") { + let _ = fs::write(&excludes_path, content); + } + } } // Configure the repo to use this excludes file if let Ok(mut config) = repo.config() { diff --git a/apps/desktop/src-tauri/src/latex.rs b/apps/desktop/src-tauri/src/latex.rs index bcec644..f3099b9 100644 --- a/apps/desktop/src-tauri/src/latex.rs +++ b/apps/desktop/src-tauri/src/latex.rs @@ -9,6 +9,9 @@ const COMPILE_TIMEOUT_SECS: u64 = 30; struct BuildInfo { work_dir: PathBuf, main_file_name: String, // stem without extension, e.g. "document" + aux_hash: Option, // hash of .aux file from last build (for single-pass optimization) + preamble_hash: Option, // hash of preamble text (for format regeneration) + fmt_ok: bool, // whether .fmt was successfully generated } #[derive(Clone)] @@ -26,10 +29,7 @@ impl Default for LatexCompilerState { } } -#[derive(serde::Serialize)] -pub struct CompileResult { - pub pdf_path: String, -} +// CompileResult is no longer needed — compile_latex returns raw PDF bytes via Response. #[derive(serde::Serialize)] pub struct SynctexResult { @@ -176,6 +176,169 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } +/// Sync only source files (.tex, .bib, .sty, .cls, .bst, images) from project to build dir. +/// Skips build artifacts (.aux, .log, .toc, .pdf, .synctex.gz, etc.) to preserve them. +fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> { + if !dst.exists() { + std::fs::create_dir_all(dst)?; + } + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + sync_source_files(&src_path, &dst_path)?; + } else { + let ext = src_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let is_artifact = matches!(ext, "aux" | "log" | "toc" | "lof" | "lot" | "out" + | "nav" | "snm" | "vrb" | "bbl" | "blg" | "fls" | "fdb_latexmk" + | "synctex" | "pdf" | "idx" | "ind" | "ilg" | "glo" | "gls" | "glg"); + let is_synctex = src_path.to_string_lossy().ends_with(".synctex.gz"); + if !is_artifact && !is_synctex { + std::fs::copy(&src_path, &dst_path)?; + } + } + } + Ok(()) +} + +/// Persistent build directory inside the project. +/// Stored in `/.prism/build/` — hidden from file tree (dot-prefix is filtered). +fn persistent_build_dir(project_dir: &str) -> PathBuf { + PathBuf::from(project_dir).join(".prism").join("build") +} + +/// Recover preamble hash from on-disk `_prism_preamble.tex` (has `\dump\n` suffix). +fn recover_preamble_hash(work_dir: &Path) -> Option { + let preamble_tex = work_dir.join("_prism_preamble.tex"); + if !preamble_tex.exists() { + return None; + } + let content = std::fs::read_to_string(&preamble_tex).ok()?; + let preamble = content + .strip_suffix("\\dump\n") + .or_else(|| content.strip_suffix("\\dump")) + .unwrap_or(&content); + Some(hash_string(preamble)) +} + +/// Hash a string (for preamble change detection). +fn hash_string(s: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} + +/// Extract preamble info from a .tex file. +/// Returns (preamble_text, byte_pos_of_begin_document, newline_count_in_preamble). +fn extract_preamble_info(content: &str) -> Option<(String, usize, usize)> { + let marker = "\\begin{document}"; + if let Some(pos) = content.find(marker) { + let preamble = content[..pos].to_string(); + let newlines = content[..pos].chars().filter(|&c| c == '\n').count(); + Some((preamble, pos, newlines)) + } else { + None + } +} + +/// Try to generate a pre-compiled format (.fmt) from the preamble. +/// Returns true if the format was successfully generated. +async fn try_generate_format( + preamble: &str, + work_dir: &Path, + compiler_cmd: &str, +) -> bool { + // Write preamble + \dump to a temp .tex file + let preamble_path = work_dir.join("_prism_preamble.tex"); + let dump_content = format!("{}\\dump\n", preamble); + if std::fs::write(&preamble_path, &dump_content).is_err() { + eprintln!("[latex] failed to write format preamble file"); + return false; + } + + let ini_load = format!("&{}", compiler_cmd); + match run_with_timeout( + compiler_cmd, + &["-ini", "-jobname=_prism_fmt", &ini_load, "_prism_preamble.tex"], + work_dir, + COMPILE_TIMEOUT_SECS, + ) + .await + { + Ok(result) => { + let fmt_path = work_dir.join("_prism_fmt.fmt"); + if fmt_path.exists() { + let size = std::fs::metadata(&fmt_path).map(|m| m.len()).unwrap_or(0); + eprintln!( + "[latex] format generated ({} bytes, exit={})", + size, result.exit_code + ); + true + } else { + eprintln!( + "[latex] format generation failed (exit={}, no .fmt)", + result.exit_code + ); + if !result.stderr.is_empty() { + let n = result.stderr.len().min(300); + eprintln!("[latex] fmt stderr: {}", &result.stderr[..n]); + } + false + } + } + Err(e) => { + eprintln!("[latex] format generation error: {}", e); + false + } + } +} + +/// Write a body file with padding comment lines to preserve line numbers. +/// The body file starts with `newline_count` padding lines, then \begin{document}... +/// For pdflatex, injects `\pdfcompresslevel=0` to speed up PDF generation. +fn write_body_file( + content: &str, + begin_doc_pos: usize, + newline_count: usize, + work_dir: &Path, + compiler: &str, +) -> std::io::Result<()> { + let body = &content[begin_doc_pos..]; + // Only inject pdfcompresslevel for pdflatex (undefined in xelatex/lualatex) + let inject = if compiler == "pdflatex" { + "\\pdfcompresslevel=0 \\pdfobjcompresslevel=0\n" + } else { + "%\n" + }; + let mut result = String::with_capacity(body.len() + newline_count * 2 + 80); + if newline_count > 1 { + for _ in 0..newline_count - 1 { + result.push_str("%\n"); + } + result.push_str(inject); + } else if newline_count == 1 { + result.push_str(inject); + } + // else newline_count == 0: no room for injection, skip + result.push_str(body); + std::fs::write(work_dir.join("_prism_body.tex"), &result) +} + +/// Hash the contents of a file (for aux-file change detection). +fn hash_file(path: &Path) -> Option { + use std::hash::{Hash, Hasher}; + let data = std::fs::read(path).ok()?; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + data.hash(&mut hasher); + Some(hasher.finish()) +} + // --- Tauri Commands --- #[tauri::command] @@ -184,7 +347,7 @@ pub async fn compile_latex( project_dir: String, main_file: String, compiler: Option, -) -> Result { +) -> Result { // Acquire semaphore permit (non-blocking) let _permit = state .semaphore @@ -192,92 +355,222 @@ pub async fn compile_latex( .try_acquire_owned() .map_err(|_| "Server busy, too many concurrent compilations".to_string())?; + let t0 = std::time::Instant::now(); + let compiler_cmd = match compiler.as_deref() { Some("xelatex") => "xelatex", Some("lualatex") => "lualatex", _ => "pdflatex", }; - // Create temp build directory - let raw_work_dir = - std::env::temp_dir().join(format!("latex-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&raw_work_dir) - .map_err(|e| format!("Failed to create temp dir: {}", e))?; - // Resolve symlinks (macOS: /var → /private/var) so synctex paths match - let work_dir = std::fs::canonicalize(&raw_work_dir).unwrap_or(raw_work_dir); - - // Clean up previous build for this project - { - let mut builds = state.last_builds.lock().await; - if let Some(prev) = builds.remove(&project_dir) { - let _ = std::fs::remove_dir_all(&prev.work_dir); - } - } - - // Copy project to temp dir - copy_dir_recursive(Path::new(&project_dir), &work_dir) - .map_err(|e| format!("Failed to copy project: {}", e))?; - let main_file_name = Path::new(&main_file) .file_stem() .and_then(|s| s.to_str()) .unwrap_or("document") .to_string(); - // Detect .bib files - let has_bib = has_bib_files(&work_dir); - let latex_args: Vec<&str> = vec!["-interaction=nonstopmode", "-synctex=1", &main_file]; - let mut last_result; - - if has_bib { - // Pass 1 - last_result = - run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS).await?; - if last_result.timed_out { - return Err("Compilation timed out".to_string()); - } - - // BibTeX - let aux_path = work_dir.join(format!("{}.aux", main_file_name)); - if aux_path.exists() { - last_result = run_with_timeout( - "bibtex", - &[main_file_name.as_str()], - &work_dir, - COMPILE_TIMEOUT_SECS, - ) - .await?; - if last_result.timed_out { - return Err("BibTeX timed out".to_string()); + // Reuse existing build directory: check in-memory cache, then persistent disk cache + let (work_dir, prev_aux_hash, prev_preamble_hash, prev_fmt_ok, is_reuse) = { + let builds = state.last_builds.lock().await; + if let Some(prev) = builds.get(&project_dir) { + if prev.work_dir.exists() { + // In-memory cache hit + ( + prev.work_dir.clone(), + prev.aux_hash, + prev.preamble_hash, + prev.fmt_ok, + true, + ) + } else { + (PathBuf::new(), None, None, false, false) + } + } else { + // Check persistent disk cache (survives app restarts) + let persistent = persistent_build_dir(&project_dir); + if persistent.exists() { + let aux_hash = + hash_file(&persistent.join(format!("{}.aux", main_file_name))); + let preamble_hash = recover_preamble_hash(&persistent); + let fmt_ok = persistent.join("_prism_fmt.fmt").exists(); + eprintln!( + "[latex] recovered persistent cache (fmt={}, aux={}, preamble={})", + fmt_ok, + aux_hash.is_some(), + preamble_hash.is_some() + ); + (persistent, aux_hash, preamble_hash, fmt_ok, true) + } else { + (PathBuf::new(), None, None, false, false) } } + }; - // Pass 2 & 3 - for _ in 0..2 { + let work_dir = if is_reuse { + // Sync only source files, preserving build artifacts (.aux, .toc, .fmt, etc.) + sync_source_files(Path::new(&project_dir), &work_dir) + .map_err(|e| format!("Failed to sync project: {}", e))?; + eprintln!( + "[latex] +{:.0}ms sync source files (reuse)", + t0.elapsed().as_millis() + ); + work_dir + } else { + // First build ever: create persistent build directory + let work_dir = persistent_build_dir(&project_dir); + std::fs::create_dir_all(&work_dir) + .map_err(|e| format!("Failed to create build dir: {}", e))?; + copy_dir_recursive(Path::new(&project_dir), &work_dir) + .map_err(|e| format!("Failed to copy project: {}", e))?; + eprintln!( + "[latex] +{:.0}ms full copy (first build)", + t0.elapsed().as_millis() + ); + work_dir + }; + + // Pre-compiled format optimization: cache preamble loading in .fmt file + let main_tex_path = work_dir.join(&main_file); + let tex_content = std::fs::read_to_string(&main_tex_path).unwrap_or_default(); + + let (use_fmt, new_preamble_hash) = + if let Some((preamble, begin_doc_pos, newline_count)) = + extract_preamble_info(&tex_content) + { + let p_hash = hash_string(&preamble); + let fmt_path = work_dir.join("_prism_fmt.fmt"); + + let need_regen = !(is_reuse + && prev_preamble_hash == Some(p_hash) + && fmt_path.exists() + && prev_fmt_ok); + + if need_regen { + eprintln!("[latex] preamble changed or no format — generating .fmt"); + let t_fmt = std::time::Instant::now(); + let ok = try_generate_format(&preamble, &work_dir, compiler_cmd).await; + eprintln!( + "[latex] +{:.0}ms format generation (ok={})", + t_fmt.elapsed().as_millis(), + ok + ); + if ok { + write_body_file(&tex_content, begin_doc_pos, newline_count, &work_dir, compiler_cmd) + .map_err(|e| format!("Failed to write body file: {}", e))?; + (true, Some(p_hash)) + } else { + (false, Some(p_hash)) + } + } else { + // Format cached and valid — just update body file (content may have changed) + write_body_file(&tex_content, begin_doc_pos, newline_count, &work_dir, compiler_cmd) + .map_err(|e| format!("Failed to write body file: {}", e))?; + eprintln!("[latex] reusing cached format"); + (true, Some(p_hash)) + } + } else { + (false, None) + }; + + eprintln!( + "[latex] +{:.0}ms format setup (use_fmt={})", + t0.elapsed().as_millis(), + use_fmt + ); + + // Detect .bib files + let has_bib = has_bib_files(&work_dir); + + // Build compilation arguments + let fmt_arg = "-fmt=_prism_fmt".to_string(); + let jobname_arg = format!("-jobname={}", main_file_name); + let latex_args: Vec<&str> = if use_fmt { + vec![ + "-interaction=nonstopmode", + "-synctex=1", + &fmt_arg, + &jobname_arg, + "_prism_body.tex", + ] + } else { + vec!["-interaction=nonstopmode", "-synctex=1", &main_file] + }; + + let mut last_result; + let mut pass_count = 0; + + // Pass 1 — always needed + last_result = + run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS).await?; + pass_count += 1; + eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code); + if last_result.timed_out { + return Err("Compilation timed out".to_string()); + } + + // Decide whether extra passes are needed + let aux_path = work_dir.join(format!("{}.aux", main_file_name)); + let aux_hash_after_pass1 = hash_file(&aux_path); + let aux_stable = prev_aux_hash.is_some() && aux_hash_after_pass1 == prev_aux_hash; + + // Check .log for rerun warnings — avoids unnecessary pass 2 even on first build + let log_path = work_dir.join(format!("{}.log", main_file_name)); + let log_after_pass1 = std::fs::read_to_string(&log_path).unwrap_or_default(); + let needs_rerun = log_after_pass1.contains("Rerun to get") + || log_after_pass1.contains("Rerun LaTeX") + || log_after_pass1.contains("Label(s) may have changed"); + + eprintln!( + "[latex] aux_stable={} needs_rerun={} prev_hash={:?} new_hash={:?}", + aux_stable, needs_rerun, prev_aux_hash, aux_hash_after_pass1 + ); + + if !aux_stable && needs_rerun { + if has_bib { + // BibTeX + 2 more passes + if aux_path.exists() { + last_result = run_with_timeout( + "bibtex", + &[main_file_name.as_str()], + &work_dir, + COMPILE_TIMEOUT_SECS, + ) + .await?; + eprintln!("[latex] +{:.0}ms bibtex done (exit={})", t0.elapsed().as_millis(), last_result.exit_code); + if last_result.timed_out { + return Err("BibTeX timed out".to_string()); + } + } + for _ in 0..2 { + last_result = + run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS) + .await?; + pass_count += 1; + eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code); + if last_result.timed_out { + return Err("Compilation timed out".to_string()); + } + } + } else { + // One more pass to resolve cross-references last_result = run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS) .await?; + pass_count += 1; + eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code); if last_result.timed_out { return Err("Compilation timed out".to_string()); } } } else { - // Two passes — beamer, hyperref, TOC, etc. need multiple passes - // to resolve cross-references, navigation elements, and page numbers - last_result = - run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS).await?; - if last_result.timed_out { - return Err("Compilation timed out".to_string()); - } - last_result = - run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS).await?; - if last_result.timed_out { - return Err("Compilation timed out".to_string()); - } + eprintln!("[latex] skipping extra passes (aux_stable={}, needs_rerun={})", aux_stable, needs_rerun); } - // Read log file - let log_path = work_dir.join(format!("{}.log", main_file_name)); + // Final aux hash for next compilation + let final_aux_hash = hash_file(&aux_path); + eprintln!("[latex] +{:.0}ms total ({} passes, reuse={}, bib={})", t0.elapsed().as_millis(), pass_count, is_reuse, has_bib); + + // Re-read log file (may have been updated by extra passes) let log_content = std::fs::read_to_string(&log_path).unwrap_or_default(); // Check for PDF; if "No pages of output", retry with \null injection @@ -297,16 +590,19 @@ pub async fn compile_latex( BuildInfo { work_dir: work_dir.clone(), main_file_name: main_file_name.clone(), + aux_hash: final_aux_hash, + preamble_hash: new_preamble_hash, + fmt_ok: use_fmt, }, ); }; if pdf_path.exists() { + let pdf_bytes = std::fs::read(&pdf_path) + .map_err(|e| format!("Failed to read PDF: {}", e))?; let mut builds = state.last_builds.lock().await; store_build(&mut builds); - Ok(CompileResult { - pdf_path: pdf_path.to_string_lossy().to_string(), - }) + Ok(tauri::ipc::Response::new(pdf_bytes)) } else { let mut builds = state.last_builds.lock().await; store_build(&mut builds); @@ -351,6 +647,7 @@ pub async fn synctex_edit( let pdf_file = format!("{}.pdf", build.main_file_name); let coord_arg = format!("{}:{}:{}:{}", page, x, y, pdf_file); let work_dir = build.work_dir.clone(); + let main_file_name = build.main_file_name.clone(); drop(builds); // Release lock before spawning process let result = run_with_timeout("synctex", &["edit", "-o", &coord_arg], &work_dir, 10).await?; @@ -388,13 +685,17 @@ pub async fn synctex_edit( file = rest.to_string(); } + // Map format body file back to original main file + if file == "_prism_body.tex" { + file = format!("{}.tex", main_file_name); + } + Ok(SynctexResult { file, line, column }) } -/// Clean up all remaining build directories on app exit. +/// Clear in-memory build state on app exit. +/// Persistent build directories are intentionally kept for fast restart. pub async fn cleanup_all_builds(state: &LatexCompilerState) { let mut builds = state.last_builds.lock().await; - for (_, build) in builds.drain() { - let _ = std::fs::remove_dir_all(&build.work_dir); - } + builds.clear(); } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 09571bc..fe6396a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,8 +3,72 @@ mod history; mod latex; mod zotero; +use std::path::Path; use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +// --- External editor detection & opening --- + +#[derive(serde::Serialize, Clone)] +struct EditorInfo { + id: String, + name: String, +} + +struct EditorDef { + id: &'static str, + name: &'static str, + app_path: &'static str, + cli: &'static str, +} + +const KNOWN_EDITORS: &[EditorDef] = &[ + EditorDef { id: "cursor", name: "Cursor", app_path: "/Applications/Cursor.app", cli: "cursor" }, + EditorDef { id: "vscode", name: "VS Code", app_path: "/Applications/Visual Studio Code.app", cli: "code" }, + EditorDef { id: "zed", name: "Zed", app_path: "/Applications/Zed.app", cli: "zed" }, + EditorDef { id: "sublime", name: "Sublime Text", app_path: "/Applications/Sublime Text.app", cli: "subl" }, +]; + +#[tauri::command] +fn detect_editors() -> Vec { + KNOWN_EDITORS + .iter() + .filter(|e| Path::new(e.app_path).exists()) + .map(|e| EditorInfo { id: e.id.to_string(), name: e.name.to_string() }) + .collect() +} + +#[tauri::command] +fn open_in_editor( + editor_id: String, + project_path: String, + file_path: Option, + line: Option, +) -> Result<(), String> { + let editor = KNOWN_EDITORS + .iter() + .find(|e| e.id == editor_id) + .ok_or_else(|| format!("Unknown editor: {}", editor_id))?; + + let mut cmd = std::process::Command::new(editor.cli); + + // Open the project folder + cmd.arg(&project_path); + + // If a specific file is given, open it (with optional line number via -g) + if let Some(ref fp) = file_path { + let full_path = Path::new(&project_path).join(fp); + if let Some(ln) = line { + cmd.arg("-g"); + cmd.arg(format!("{}:{}", full_path.display(), ln)); + } else { + cmd.arg(full_path); + } + } + + cmd.spawn().map_err(|e| format!("Failed to open {}: {}", editor.name, e))?; + Ok(()) +} + #[cfg(target_os = "macos")] fn set_macos_app_icon() { use objc2::{AnyThread, MainThreadMarker}; @@ -72,6 +136,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ create_new_window, + detect_editors, + open_in_editor, latex::compile_latex, latex::synctex_edit, claude::check_claude_status, diff --git a/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx b/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx index 5124291..a6178ca 100644 --- a/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx +++ b/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx @@ -1,7 +1,6 @@ - - -import { RefObject } from "react"; +import { RefObject, useCallback, useEffect, useState } from "react"; import type { EditorView } from "@codemirror/view"; +import { invoke } from "@tauri-apps/api/core"; import { BoldIcon, ItalicIcon, @@ -15,9 +14,16 @@ import { MinusIcon, PlusIcon, BookMarkedIcon, + ExternalLinkIcon, } from "lucide-react"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Select, SelectContent, @@ -27,6 +33,11 @@ import { } from "@/components/ui/select"; import { useDocumentStore } from "@/stores/document-store"; +interface EditorInfo { + id: string; + name: string; +} + const ZOOM_OPTIONS = [ { value: "0.5", label: "50%" }, { value: "0.75", label: "75%" }, @@ -55,6 +66,34 @@ export function EditorToolbar({ const activeFile = s.files.find((f) => f.id === s.activeFileId); return activeFile?.name ?? "document.tex"; }); + const activeFilePath = useDocumentStore((s) => { + const activeFile = s.files.find((f) => f.id === s.activeFileId); + return activeFile?.relativePath; + }); + const projectRoot = useDocumentStore((s) => s.projectRoot); + + const [editors, setEditors] = useState([]); + + useEffect(() => { + invoke("detect_editors").then(setEditors).catch(() => {}); + }, []); + + const openInEditor = useCallback( + (editorId: string) => { + if (!projectRoot) return; + const view = editorView.current; + const line = view + ? view.state.doc.lineAt(view.state.selection.main.head).number + : undefined; + invoke("open_in_editor", { + editorId, + projectPath: projectRoot, + filePath: activeFilePath, + line, + }).catch((err) => console.error("open_in_editor failed:", err)); + }, + [projectRoot, activeFilePath, editorView], + ); const insertText = (before: string, after: string = "") => { const view = editorView.current; @@ -128,6 +167,38 @@ export function EditorToolbar({ ))} + {editors.length === 1 && ( + openInEditor(editors[0].id)} + > + + + )} + {editors.length > 1 && ( + + + + + + {editors.map((editor) => ( + openInEditor(editor.id)} + > + {editor.name} + + ))} + + + )} ); @@ -198,6 +269,38 @@ export function EditorToolbar({
+ {editors.length === 1 && ( + openInEditor(editors[0].id)} + > + + + )} + {editors.length > 1 && ( + + + + + + {editors.map((editor) => ( + openInEditor(editor.id)} + > + {editor.name} + + ))} + + + )}
); } diff --git a/apps/desktop/src/lib/latex-compiler.ts b/apps/desktop/src/lib/latex-compiler.ts index 12bd733..a358304 100644 --- a/apps/desktop/src/lib/latex-compiler.ts +++ b/apps/desktop/src/lib/latex-compiler.ts @@ -1,22 +1,18 @@ import { invoke } from "@tauri-apps/api/core"; -import { readFile } from "@tauri-apps/plugin-fs"; - -interface CompileResult { - pdf_path: string; -} export async function compileLatex( projectDir: string, mainFile: string = "document.tex", compiler?: string, ): Promise { - const result = await invoke("compile_latex", { + // compile_latex returns raw PDF bytes via Tauri IPC Response + const buffer = await invoke("compile_latex", { projectDir, mainFile, compiler: compiler ?? null, }); - return readFile(result.pdf_path); + return new Uint8Array(buffer); } export interface SynctexResult { diff --git a/apps/desktop/src/lib/mupdf/mupdf-worker.ts b/apps/desktop/src/lib/mupdf/mupdf-worker.ts index 06f2ab2..07569f4 100644 --- a/apps/desktop/src/lib/mupdf/mupdf-worker.ts +++ b/apps/desktop/src/lib/mupdf/mupdf-worker.ts @@ -39,13 +39,21 @@ methods.drawPage = (docId: number, pageIndex: number, dpi: number): ImageData => const page = doc.loadPage(pageIndex); const scale = dpi / 72; const matrix = mupdf.Matrix.scale(scale, scale); - // alpha=true to get RGBA pixels compatible with ImageData - const pixmap = page.toPixmap(matrix, mupdf.ColorSpace.DeviceRGB, true, true); + // alpha=false so the PDF's white background is rendered opaquely (RGB, 3 bytes/pixel) + const pixmap = page.toPixmap(matrix, mupdf.ColorSpace.DeviceRGB, false, true); const w = pixmap.getWidth(); const h = pixmap.getHeight(); - const pixels = pixmap.getPixels().slice(); + const rgb = pixmap.getPixels(); pixmap.destroy(); - return new ImageData(new Uint8ClampedArray(pixels.buffer), w, h); + // Convert RGB (3 bytes/pixel) → RGBA (4 bytes/pixel) for ImageData + const rgba = new Uint8ClampedArray(w * h * 4); + for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) { + rgba[j] = rgb[i]; + rgba[j + 1] = rgb[i + 1]; + rgba[j + 2] = rgb[i + 2]; + rgba[j + 3] = 255; // fully opaque + } + return new ImageData(rgba, w, h); }; methods.getPageText = (docId: number, pageIndex: number): unknown => { diff --git a/apps/desktop/src/stores/document-store.ts b/apps/desktop/src/stores/document-store.ts index 63ecde4..39011d5 100644 --- a/apps/desktop/src/stores/document-store.ts +++ b/apps/desktop/src/stores/document-store.ts @@ -15,6 +15,7 @@ import { type ProjectFileType, } from "@/lib/tauri/fs"; import { useHistoryStore } from "@/stores/history-store"; +import { useClaudeChatStore } from "@/stores/claude-chat-store"; export interface ProjectFile { id: string; // relativePath is the id @@ -188,6 +189,8 @@ export const useDocumentStore = create()((set, get) => ({ compileError: null, initialized: false, }); + // Reset chat session so stale messages don't leak into the next project + useClaudeChatStore.getState().newSession(); }, setActiveFile: (id) =>