feat: implement external editor detection and opening functionality

This commit is contained in:
delibae 2026-02-25 10:27:27 +09:00
parent 1e35ba2ae3
commit 08ce221e22
7 changed files with 574 additions and 89 deletions

View file

@ -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() {

View file

@ -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<u64>, // hash of .aux file from last build (for single-pass optimization)
preamble_hash: Option<u64>, // 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 `<project>/.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<u64> {
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<u64> {
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<String>,
) -> Result<CompileResult, String> {
) -> Result<tauri::ipc::Response, String> {
// 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();
}

View file

@ -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<EditorInfo> {
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<String>,
line: Option<u32>,
) -> 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,

View file

@ -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<EditorInfo[]>([]);
useEffect(() => {
invoke<EditorInfo[]>("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({
))}
</SelectContent>
</Select>
{editors.length === 1 && (
<TooltipIconButton
tooltip={`Open in ${editors[0].name}`}
onClick={() => openInEditor(editors[0].id)}
>
<ExternalLinkIcon className="size-4" />
</TooltipIconButton>
)}
{editors.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
title="Open in Editor"
>
<ExternalLinkIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{editors.map((editor) => (
<DropdownMenuItem
key={editor.id}
onClick={() => openInEditor(editor.id)}
>
{editor.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
@ -198,6 +269,38 @@ export function EditorToolbar({
<BookMarkedIcon className="size-4" />
</TooltipIconButton>
<div data-tauri-drag-region className="flex-1 self-stretch" />
{editors.length === 1 && (
<TooltipIconButton
tooltip={`Open in ${editors[0].name}`}
onClick={() => openInEditor(editors[0].id)}
>
<ExternalLinkIcon className="size-4" />
</TooltipIconButton>
)}
{editors.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
title="Open in Editor"
>
<ExternalLinkIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{editors.map((editor) => (
<DropdownMenuItem
key={editor.id}
onClick={() => openInEditor(editor.id)}
>
{editor.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View file

@ -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<Uint8Array> {
const result = await invoke<CompileResult>("compile_latex", {
// compile_latex returns raw PDF bytes via Tauri IPC Response
const buffer = await invoke<ArrayBuffer>("compile_latex", {
projectDir,
mainFile,
compiler: compiler ?? null,
});
return readFile(result.pdf_path);
return new Uint8Array(buffer);
}
export interface SynctexResult {

View file

@ -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 => {

View file

@ -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<DocumentState>()((set, get) => ({
compileError: null,
initialized: false,
});
// Reset chat session so stale messages don't leak into the next project
useClaudeChatStore.getState().newSession();
},
setActiveFile: (id) =>