diff --git a/apps/desktop/src-tauri/src/latex.rs b/apps/desktop/src-tauri/src/latex.rs index afb0b53..233d081 100644 --- a/apps/desktop/src-tauri/src/latex.rs +++ b/apps/desktop/src-tauri/src/latex.rs @@ -358,6 +358,10 @@ pub async fn compile_latex( ); } + // Remove stale PDF so a failed compile doesn't return the previous result. + let pdf_path = work_dir.join(format!("{}.pdf", main_file_name)); + let _ = std::fs::remove_file(&pdf_path); + // Run Tectonic in a blocking task (it uses an internal global mutex) let work_dir_clone = work_dir.clone(); let main_file_clone = main_file.clone(); @@ -373,7 +377,6 @@ pub async fn compile_latex( compile_result.is_ok() ); - let pdf_path = work_dir.join(format!("{}.pdf", main_file_name)); let log_path = work_dir.join(format!("{}.log", main_file_name)); // Handle "No pages of output" — retry with \AtEndDocument{\null} injection @@ -893,4 +896,86 @@ Postamble: assert!(!dst.path().join("chapters").join("ch1.aux").exists()); assert!(!dst.path().join(".claudeprism").exists()); } + + // --- sync_source_files preserves stale PDF (compile_latex deletes it separately) --- + + #[test] + fn test_sync_source_files_does_not_copy_pdf_from_source() { + // sync_source_files treats .pdf as an artifact and does not copy it. + // This means a stale PDF in the build dir survives a sync — the + // compile_latex command must delete it explicitly before compiling. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + + std::fs::write(src.path().join("main.tex"), "new content").unwrap(); + // Simulate a stale PDF already sitting in the build dir + std::fs::write(dst.path().join("main.pdf"), "old pdf bytes").unwrap(); + + sync_source_files(src.path(), dst.path()).unwrap(); + + // Source .tex was synced + assert_eq!( + std::fs::read_to_string(dst.path().join("main.tex")).unwrap(), + "new content" + ); + // Stale PDF in dst was NOT overwritten (sync skips .pdf) + // This confirms that compile_latex must delete the PDF itself + assert!(dst.path().join("main.pdf").exists()); + assert_eq!( + std::fs::read_to_string(dst.path().join("main.pdf")).unwrap(), + "old pdf bytes" + ); + } + + #[test] + fn test_sync_source_files_overwrites_changed_tex_content() { + // Regression: when a user empties a file, sync must overwrite + // the old content in the build dir with the empty content. + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + + // Old content in build dir + std::fs::write(dst.path().join("main.tex"), "old content").unwrap(); + // User emptied the file + std::fs::write(src.path().join("main.tex"), "").unwrap(); + + sync_source_files(src.path(), dst.path()).unwrap(); + + assert_eq!( + std::fs::read_to_string(dst.path().join("main.tex")).unwrap(), + "" + ); + } + + // --- persistent_build_dir --- + + #[test] + fn test_stale_pdf_removal_pattern() { + // Simulates the pattern used in compile_latex: remove stale PDF + // before compilation so a failed compile doesn't return old results. + let build_dir = tempfile::tempdir().unwrap(); + let pdf_path = build_dir.path().join("document.pdf"); + + // Simulate previous successful build left a PDF + std::fs::write(&pdf_path, "old pdf data").unwrap(); + assert!(pdf_path.exists()); + + // This is what compile_latex does before running tectonic + let _ = std::fs::remove_file(&pdf_path); + assert!(!pdf_path.exists()); + + // If compilation fails, pdf_path.exists() is false → error returned + } + + #[test] + fn test_stale_pdf_removal_no_existing_file() { + // remove_file on a non-existent path should not panic (we use let _ =) + let build_dir = tempfile::tempdir().unwrap(); + let pdf_path = build_dir.path().join("document.pdf"); + + assert!(!pdf_path.exists()); + let result = std::fs::remove_file(&pdf_path); + // It's an error but we ignore it with let _ = + assert!(result.is_err()); + } } diff --git a/apps/desktop/src/__tests__/lib/tauri-error-handling.test.ts b/apps/desktop/src/__tests__/lib/tauri-error-handling.test.ts new file mode 100644 index 0000000..d09bfbf --- /dev/null +++ b/apps/desktop/src/__tests__/lib/tauri-error-handling.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; + +/** + * Regression tests: Tauri IPC error handling. + * + * When a Rust #[tauri::command] returns Err(String), the frontend's + * `invoke()` rejects with a **plain string**, NOT an Error object. + * Catch blocks that only check `error instanceof Error` will miss these + * and fall through to a generic message, losing the actual error details. + * + * The correct pattern is: + * error instanceof Error ? error.message + * : typeof error === "string" ? error + * : "Compilation failed" + */ + +/** Helper that mirrors the pattern used in catch blocks */ +function extractErrorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Compilation failed"; +} + +describe("Tauri IPC error message extraction", () => { + it("extracts message from a standard Error object", () => { + const error = new Error("Something went wrong"); + expect(extractErrorMessage(error)).toBe("Something went wrong"); + }); + + it("extracts message from a plain string (Tauri Err(String) pattern)", () => { + // This is what Tauri invoke() rejects with for Rust Err(String) + const error = "Compilation failed\n\n! Undefined control sequence."; + expect(extractErrorMessage(error)).toBe( + "Compilation failed\n\n! Undefined control sequence." + ); + }); + + it("extracts message from an empty string", () => { + expect(extractErrorMessage("")).toBe(""); + }); + + it("falls back to generic message for non-string, non-Error values", () => { + expect(extractErrorMessage(42)).toBe("Compilation failed"); + expect(extractErrorMessage(null)).toBe("Compilation failed"); + expect(extractErrorMessage(undefined)).toBe("Compilation failed"); + expect(extractErrorMessage({ code: 1 })).toBe("Compilation failed"); + }); + + it("a plain string is NOT instanceof Error (the root cause of the bug)", () => { + const tauriError: unknown = "Compilation failed\n\n! Missing $ inserted."; + expect(tauriError instanceof Error).toBe(false); + expect(typeof tauriError === "string").toBe(true); + }); +}); diff --git a/apps/desktop/src/__tests__/stores/document-store.test.ts b/apps/desktop/src/__tests__/stores/document-store.test.ts index 90339a7..79ae3dc 100644 --- a/apps/desktop/src/__tests__/stores/document-store.test.ts +++ b/apps/desktop/src/__tests__/stores/document-store.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; import { useDocumentStore, type ProjectFile } from "@/stores/document-store"; // Mock history store @@ -252,4 +253,107 @@ describe("useDocumentStore", () => { expect(state.selectionRange).toBeNull(); }); }); + + describe("saveFile", () => { + beforeEach(() => { + vi.mocked(writeTextFile).mockClear(); + vi.mocked(writeTextFile).mockResolvedValue(undefined); + }); + + it("saves a dirty file with content to disk", async () => { + useDocumentStore.setState({ + files: [makeFile({ isDirty: true, content: "saved content" })], + }); + await useDocumentStore.getState().saveFile("main.tex"); + expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "saved content"); + expect(useDocumentStore.getState().files[0].isDirty).toBe(false); + }); + + it("saves a dirty file with empty string content (regression: empty content is not falsy-skipped)", async () => { + useDocumentStore.setState({ + files: [makeFile({ isDirty: true, content: "" })], + }); + await useDocumentStore.getState().saveFile("main.tex"); + expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", ""); + expect(useDocumentStore.getState().files[0].isDirty).toBe(false); + }); + + it("skips saving when content is null", async () => { + useDocumentStore.setState({ + files: [makeFile({ isDirty: true, content: null as unknown as string })], + }); + await useDocumentStore.getState().saveFile("main.tex"); + expect(writeTextFile).not.toHaveBeenCalled(); + }); + + it("skips saving when file is not dirty", async () => { + useDocumentStore.setState({ + files: [makeFile({ isDirty: false, content: "clean" })], + }); + await useDocumentStore.getState().saveFile("main.tex"); + expect(writeTextFile).not.toHaveBeenCalled(); + }); + }); + + describe("saveAllFiles", () => { + beforeEach(() => { + vi.mocked(writeTextFile).mockClear(); + vi.mocked(writeTextFile).mockResolvedValue(undefined); + }); + + it("saves all dirty files", async () => { + useDocumentStore.setState({ + files: [ + makeFile({ isDirty: true, content: "dirty content" }), + makeFile({ id: "clean.tex", name: "clean.tex", absolutePath: "/project/clean.tex", relativePath: "clean.tex", isDirty: false, content: "clean" }), + ], + }); + await useDocumentStore.getState().saveAllFiles(); + expect(writeTextFile).toHaveBeenCalledTimes(1); + expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "dirty content"); + }); + + it("saves dirty files with empty string content (regression: empty string is not falsy-skipped)", async () => { + useDocumentStore.setState({ + files: [ + makeFile({ isDirty: true, content: "" }), + makeFile({ id: "slide.tex", name: "slide.tex", absolutePath: "/project/slide.tex", relativePath: "slide.tex", isDirty: true, content: "" }), + ], + }); + await useDocumentStore.getState().saveAllFiles(); + expect(writeTextFile).toHaveBeenCalledTimes(2); + expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", ""); + expect(writeTextFile).toHaveBeenCalledWith("/project/slide.tex", ""); + // Both should be marked clean + const files = useDocumentStore.getState().files; + expect(files.every((f) => !f.isDirty)).toBe(true); + }); + + it("skips files with null content even if dirty", async () => { + useDocumentStore.setState({ + files: [ + makeFile({ isDirty: true, content: null as unknown as string }), + ], + }); + await useDocumentStore.getState().saveAllFiles(); + expect(writeTextFile).not.toHaveBeenCalled(); + }); + }); + + describe("setPdfData / setCompileError", () => { + it("setPdfData clears compile error", () => { + useDocumentStore.setState({ compileError: "some error" }); + useDocumentStore.getState().setPdfData(new Uint8Array([1, 2, 3])); + const state = useDocumentStore.getState(); + expect(state.pdfData).toEqual(new Uint8Array([1, 2, 3])); + expect(state.compileError).toBeNull(); + }); + + it("setCompileError stores the error string (regression: Tauri string errors must be preserved)", () => { + useDocumentStore.getState().setCompileError("Compilation failed\n\n! Undefined control sequence."); + expect(useDocumentStore.getState().compileError).toBe( + "Compilation failed\n\n! Undefined control sequence." + ); + }); + }); }); diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index 667e0ac..56022d6 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -30,7 +30,7 @@ function getFileIcon(file: ProjectFile) { return ; } -export const ChatComposer: FC = () => { +export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const sendPrompt = useClaudeChatStore((s) => s.sendPrompt); const cancelExecution = useClaudeChatStore((s) => s.cancelExecution); const isStreaming = useClaudeChatStore((s) => s.isStreaming); @@ -86,6 +86,15 @@ export const ChatComposer: FC = () => { const pendingAttachments = useClaudeChatStore((s) => s.pendingAttachments); const consumePendingAttachments = useClaudeChatStore((s) => s.consumePendingAttachments); + // Focus textarea when the drawer opens + const prevOpenRef = useRef(false); + useEffect(() => { + if (isOpen && !prevOpenRef.current) { + setTimeout(() => textareaRef.current?.focus(), 0); + } + prevOpenRef.current = !!isOpen; + }, [isOpen]); + useEffect(() => { if (pendingAttachments.length === 0) return; const attachments = consumePendingAttachments(); @@ -697,7 +706,6 @@ export const ChatComposer: FC = () => { onPaste={handlePaste} placeholder="Ask me anything (/ for commands, @ to mention)" className="max-h-40 min-h-10 w-full resize-none bg-transparent px-4 py-2 text-sm outline-none placeholder:text-muted-foreground" - autoFocus rows={1} /> )} diff --git a/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx b/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx index a108363..9e82748 100644 --- a/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx +++ b/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx @@ -145,7 +145,7 @@ export function ClaudeChatDrawer() { {/* Composer */} - + ); diff --git a/apps/desktop/src/components/workspace/editor/latex-editor.tsx b/apps/desktop/src/components/workspace/editor/latex-editor.tsx index 0203841..9d78df1 100644 --- a/apps/desktop/src/components/workspace/editor/latex-editor.tsx +++ b/apps/desktop/src/components/workspace/editor/latex-editor.tsx @@ -278,7 +278,7 @@ export function LatexEditor() { const data = await compileLatex(projectRoot, targetFile); setPdfData(data); } catch (error) { - setCompileError(error instanceof Error ? error.message : "Compilation failed"); + setCompileError(error instanceof Error ? error.message : typeof error === "string" ? error : "Compilation failed"); } finally { setIsCompiling(false); } @@ -782,7 +782,7 @@ export function LatexEditor() { )} {/* Selection toolbar */} - {toolbarPosition && selectionLabel && !isMergeActiveRef.current && ( + {toolbarPosition && selectionLabel && !isMergeActiveRef.current && !isSearchOpen && ( s.files); const saveAllFiles = useDocumentStore((s) => s.saveAllFiles); const setActiveFile = useDocumentStore((s) => s.setActiveFile); - const activeFileType = useDocumentStore((s) => { - const active = s.files.find((f) => f.id === s.activeFileId); - return active?.type ?? "tex"; + const activeFile = useDocumentStore((s) => { + return s.files.find((f) => f.id === s.activeFileId) ?? null; }); + const activeFileType = activeFile?.type ?? "tex"; const isTexActive = activeFileType === "tex"; const requestJumpToPosition = useDocumentStore( (s) => s.requestJumpToPosition, @@ -277,20 +277,21 @@ export function PdfPreview() { setIsCompiling(true); try { await saveAllFiles(); - const mainFile = files.find((f) => f.name === "document.tex" || f.name === "main.tex"); - const mainFileName = mainFile?.relativePath || "document.tex"; - const data = await compileLatex(projectRoot, mainFileName); + const targetFile = activeFile?.type === "tex" + ? activeFile.relativePath + : (files.find((f) => f.name === "document.tex" || f.name === "main.tex")?.relativePath || "document.tex"); + const data = await compileLatex(projectRoot, targetFile); setPdfData(data); } catch (error) { setCompileError( - error instanceof Error ? error.message : "Compilation failed", + error instanceof Error ? error.message : typeof error === "string" ? error : "Compilation failed", ); } finally { setIsCompiling(false); } }; compile(); - }, [initialized, projectRoot, pdfData, isCompiling, compileError, setIsCompiling, setPdfData, setCompileError, saveAllFiles, files]); + }, [initialized, projectRoot, pdfData, isCompiling, compileError, setIsCompiling, setPdfData, setCompileError, saveAllFiles, files, activeFile]); const zoomIn = () => setScale((s) => Math.min(4, s + 0.1)); const zoomOut = () => setScale((s) => Math.max(0.25, s - 0.1)); @@ -320,12 +321,13 @@ export function PdfPreview() { setPdfError(null); try { await saveAllFiles(); - const mainFile = files.find((f) => f.name === "document.tex" || f.name === "main.tex"); - const mainFileName = mainFile?.relativePath || "document.tex"; - const data = await compileLatex(projectRoot, mainFileName); + const targetFile = activeFile?.type === "tex" + ? activeFile.relativePath + : (files.find((f) => f.name === "document.tex" || f.name === "main.tex")?.relativePath || "document.tex"); + const data = await compileLatex(projectRoot, targetFile); setPdfData(data); } catch (error) { - setCompileError(error instanceof Error ? error.message : "Compilation failed"); + setCompileError(error instanceof Error ? error.message : typeof error === "string" ? error : "Compilation failed"); } finally { setIsCompiling(false); } diff --git a/apps/desktop/src/hooks/use-claude-events.ts b/apps/desktop/src/hooks/use-claude-events.ts index 8fda935..e600003 100644 --- a/apps/desktop/src/hooks/use-claude-events.ts +++ b/apps/desktop/src/hooks/use-claude-events.ts @@ -244,22 +244,28 @@ export function useClaudeEvents() { const docStore = useDocumentStore.getState(); await docStore.refreshFiles(); - // Auto-recompile after Claude finishes — always attempt if a tex file exists. + // Auto-recompile after Claude finishes. + // Prefer the active file if it's a .tex file; fall back to document.tex / main.tex. // Skip if another compilation is already in progress (e.g. initial compile). - const { projectRoot, files, isCompiling: alreadyCompiling } = useDocumentStore.getState(); + const { projectRoot, files, activeFileId, isCompiling: alreadyCompiling } = useDocumentStore.getState(); if (projectRoot && !alreadyCompiling) { - const mainFile = files.find( - (f) => f.name === "document.tex" || f.name === "main.tex", - ); - if (mainFile) { - const mainFileName = mainFile.relativePath; + const activeFile = files.find((f) => f.id === activeFileId); + const targetFile = + activeFile?.type === "tex" + ? activeFile + : files.find((f) => f.name === "document.tex" || f.name === "main.tex"); + if (targetFile) { + const mainFileName = targetFile.relativePath; useDocumentStore.getState().setIsCompiling(true); try { + // Flush any dirty files to disk before compiling so the + // compiler (which reads from disk) sees the latest content. + await useDocumentStore.getState().saveAllFiles(); const pdfData = await compileLatex(projectRoot, mainFileName); useDocumentStore.getState().setPdfData(pdfData); } catch (err) { useDocumentStore.getState().setCompileError( - err instanceof Error ? err.message : "Compilation failed", + err instanceof Error ? err.message : typeof err === "string" ? err : "Compilation failed", ); } finally { useDocumentStore.getState().setIsCompiling(false); diff --git a/apps/desktop/src/stores/document-store.ts b/apps/desktop/src/stores/document-store.ts index 8194e4d..625e907 100644 --- a/apps/desktop/src/stores/document-store.ts +++ b/apps/desktop/src/stores/document-store.ts @@ -95,7 +95,7 @@ function scheduleAutoSave() { const store = storeRef; if (!store) return; const state = store.getState(); - const dirtyFiles = state.files.filter((f) => f.isDirty && f.content); + const dirtyFiles = state.files.filter((f) => f.isDirty && f.content != null); if (dirtyFiles.length > 0) { await state.saveAllFiles(); } @@ -347,7 +347,7 @@ export const useDocumentStore = create()((set, get) => ({ saveFile: async (id) => { const state = get(); const file = state.files.find((f) => f.id === id); - if (!file || !file.isDirty || !file.content) return; + if (!file || !file.isDirty || file.content == null) return; await writeTexFileContent(file.absolutePath, file.content); set((s) => ({ @@ -359,7 +359,7 @@ export const useDocumentStore = create()((set, get) => ({ saveAllFiles: async () => { const state = get(); - const dirtyFiles = state.files.filter((f) => f.isDirty && f.content); + const dirtyFiles = state.files.filter((f) => f.isDirty && f.content != null); const results = await Promise.allSettled( dirtyFiles.map((f) => writeTexFileContent(f.absolutePath, f.content!)), );