diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 49ef451..1927816 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -486,6 +486,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha1", + "tar", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -4978,6 +4979,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -7334,6 +7346,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xdg" version = "2.5.2" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 85923d3..906b63e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tectonic = { version = "0.15", default-features = true, features = ["external-ha # Linux: apt install libicu-dev libgraphite2-dev libharfbuzz-dev libfreetype-dev libfontconfig-dev # Windows: vcpkg install harfbuzz[graphite2] freetype icu fontconfig (with TECTONIC_DEP_BACKEND=vcpkg) flate2 = "1" +tar = "0.4" [dev-dependencies] tempfile = "3" diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index 34dca0e..f72f13d 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -150,20 +150,34 @@ fn create_command(program: &str, args: Vec, cwd: &str, effort_level: Opt // Set effort level (default: low for fast responses) cmd.env("CLAUDE_CODE_EFFORT_LEVEL", effort_level.unwrap_or("low")); + // Build PATH: start with current PATH, prepend program dir and venv bin + let mut current_path = std::env::var("PATH").unwrap_or_default(); + #[cfg(target_os = "windows")] + let sep = ";"; + #[cfg(not(target_os = "windows"))] + let sep = ":"; + // Add the program's parent directory to PATH if not already present if let Some(program_dir) = std::path::Path::new(program).parent() { - let current_path = std::env::var("PATH").unwrap_or_default(); let program_dir_str = program_dir.to_string_lossy(); if !current_path.contains(program_dir_str.as_ref()) { - #[cfg(target_os = "windows")] - let sep = ";"; - #[cfg(not(target_os = "windows"))] - let sep = ":"; - let new_path = format!("{}{}{}", program_dir_str, sep, current_path); - cmd.env("PATH", new_path); + current_path = format!("{}{}{}", program_dir_str, sep, current_path); } } + // Auto-detect project venv and inject VIRTUAL_ENV + PATH + let venv_dir = std::path::Path::new(cwd).join(".venv"); + if venv_dir.exists() { + cmd.env("VIRTUAL_ENV", &venv_dir); + #[cfg(not(target_os = "windows"))] + let venv_bin = venv_dir.join("bin"); + #[cfg(target_os = "windows")] + let venv_bin = venv_dir.join("Scripts"); + current_path = format!("{}{}{}", venv_bin.to_string_lossy(), sep, current_path); + } + + cmd.env("PATH", current_path); + cmd } @@ -576,7 +590,11 @@ fn common_claude_args() -> Vec { "4. PRESERVE EXISTING CONTENT: Always read the file first. Keep the existing preamble, packages, ", "and structure intact. Only add or modify what is needed for the current step.\n", "5. LaTeX BEST PRACTICES: Use proper sectioning (\\chapter, \\section, \\subsection), ", - "citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies." + "citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies.\n", + "6. SKILLS: If scientific skills are installed in .claude/skills/, follow their guidelines ", + "for domain-specific tasks. Use skill-provided LaTeX packages (.sty) and code patterns.\n", + "7. PYTHON: If a .venv/ exists in the project, it is already activated. ", + "Use `uv pip install` to add packages and `python` to run scripts." ).to_string(), ] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dd468d0..b31917d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ mod claude; mod history; mod latex; +mod skills; mod slash_commands; +mod uv; mod zotero; use std::path::Path; @@ -185,6 +187,18 @@ pub fn run() { slash_commands::slash_command_get, slash_commands::slash_command_save, slash_commands::slash_command_delete, + skills::install_scientific_skills, + skills::install_scientific_skills_global, + skills::check_skills_installed, + skills::list_installed_skills, + skills::uninstall_scientific_skills, + skills::get_skill_categories, + skills::get_skill_content, + uv::check_uv_status, + uv::install_uv, + uv::setup_project_venv, + uv::uv_add_packages, + uv::uv_run_command, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/apps/desktop/src-tauri/src/skills.rs b/apps/desktop/src-tauri/src/skills.rs new file mode 100644 index 0000000..4c2b641 --- /dev/null +++ b/apps/desktop/src-tauri/src/skills.rs @@ -0,0 +1,761 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tokio::process::Command; + +const REPO_URL: &str = "https://github.com/K-Dense-AI/claude-scientific-skills.git"; +const TARBALL_URL: &str = + "https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz"; +const SKILLS_SUBFOLDER: &str = "scientific-skills"; + +// ─── Data Types ─── + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SkillInfo { + pub id: String, + pub name: String, + pub domain: String, + pub description: String, + pub folder: String, +} + +#[derive(Debug, Serialize)] +pub struct InstallResult { + pub success: bool, + pub skills_installed: usize, + pub target_dir: String, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct SkillsStatus { + pub installed: bool, + pub skill_count: usize, + pub location: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct SkillEntry { + pub name: String, + pub folder: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct SkillCategory { + pub id: String, + pub name: String, + pub icon: String, + pub skill_count: usize, + pub skills: Vec, +} + +// ─── Skill Categories Data ─── + +/// Returns the known scientific skill categories with metadata. +fn skill_categories() -> Vec { + fn s(name: &str, folder: &str) -> SkillEntry { + SkillEntry { name: name.into(), folder: folder.into() } + } + + let mut cats = vec![ + SkillCategory { + id: "bioinformatics".into(), + name: "Bioinformatics & Genomics".into(), + icon: "dna".into(), + skill_count: 0, + skills: vec![ + s("Scanpy (scRNA-seq)", "scanpy"), + s("BioPython", "biopython"), + s("PyDESeq2", "pydeseq2"), + s("PySAM", "pysam"), + s("gget", "gget"), + s("scikit-bio", "scikit-bio"), + s("DeepTools", "deeptools"), + s("CELLxGENE Census", "cellxgene-census"), + s("AnnData", "anndata"), + s("GTARS", "gtars"), + s("ETE Toolkit", "etetoolkit"), + s("TileDB-VCF", "tiledbvcf"), + s("FlowIO", "flowio"), + s("GenIML", "geniml"), + s("Ensembl Database", "ensembl-database"), + s("Gene Database", "gene-database"), + ], + }, + SkillCategory { + id: "cheminformatics".into(), + name: "Cheminformatics & Drug Discovery".into(), + icon: "flask-conical".into(), + skill_count: 0, + skills: vec![ + s("RDKit", "rdkit"), + s("Datamol", "datamol"), + s("MolFeat", "molfeat"), + s("MedChem Filters", "medchem"), + s("DeepChem", "deepchem"), + s("PubChem Database", "pubchem-database"), + s("ChEMBL Database", "chembl-database"), + s("ZINC Database", "zinc-database"), + s("TorchDrug", "torchdrug"), + s("DiffDock", "diffdock"), + s("Rowan", "rowan"), + ], + }, + SkillCategory { + id: "clinical".into(), + name: "Clinical Research".into(), + icon: "heart-pulse".into(), + skill_count: 0, + skills: vec![ + s("ClinicalTrials.gov", "clinicaltrials-database"), + s("ClinVar Database", "clinvar-database"), + s("ClinPGx Database", "clinpgx-database"), + s("Treatment Plans", "treatment-plans"), + s("Clinical Reports", "clinical-reports"), + s("Clinical Decision Support", "clinical-decision-support"), + s("DrugBank Database", "drugbank-database"), + s("FDA Database", "fda-database"), + s("BRENDA Database", "brenda-database"), + s("PyTDC", "pytdc"), + s("ISO 13485 Certification", "iso-13485-certification"), + s("COSMIC Database", "cosmic-database"), + ], + }, + SkillCategory { + id: "data-analysis".into(), + name: "Data Analysis & Visualization".into(), + icon: "bar-chart-3".into(), + skill_count: 0, + skills: vec![ + s("Statistical Analysis", "statistical-analysis"), + s("Exploratory Data Analysis", "exploratory-data-analysis"), + s("Polars", "polars"), + s("Dask", "dask"), + s("Vaex", "vaex"), + s("NetworkX", "networkx"), + s("Seaborn", "seaborn"), + s("Plotly", "plotly"), + s("Matplotlib", "matplotlib"), + s("Scientific Visualization", "scientific-visualization"), + s("Zarr", "zarr-python"), + s("Data Commons", "datacommons-client"), + s("Aeon (Time Series ML)", "aeon"), + s("TimesFM Forecasting", "timesfm-forecasting"), + ], + }, + SkillCategory { + id: "ml-ai".into(), + name: "Machine Learning & AI".into(), + icon: "brain".into(), + skill_count: 0, + skills: vec![ + s("scikit-learn", "scikit-learn"), + s("Transformers", "transformers"), + s("PyTorch Lightning", "pytorch-lightning"), + s("PyG (Graph Neural Nets)", "torch_geometric"), + s("Stable Baselines3", "stable-baselines3"), + s("PufferLib", "pufferlib"), + s("SHAP", "shap"), + s("UMAP", "umap-learn"), + s("HypoGeniC", "hypogenic"), + s("Hypothesis Generation", "hypothesis-generation"), + s("Statsmodels", "statsmodels"), + s("PyMC", "pymc"), + s("PennyLane", "pennylane"), + s("Qiskit", "qiskit"), + s("Cirq", "cirq"), + ], + }, + SkillCategory { + id: "scientific-communication".into(), + name: "Scientific Communication".into(), + icon: "book-open".into(), + skill_count: 0, + skills: vec![ + s("Scientific Writing", "scientific-writing"), + s("Literature Review", "literature-review"), + s("Peer Review", "peer-review"), + s("Grant Writing", "research-grants"), + s("Citation Management", "citation-management"), + s("Scientific Slides", "scientific-slides"), + s("LaTeX Posters", "latex-posters"), + s("HTML/PPTX Posters", "pptx-posters"), + s("Infographics", "infographics"), + s("Scientific Schematics", "scientific-schematics"), + s("Markdown & Mermaid", "markdown-mermaid-writing"), + s("Scientific Brainstorming", "scientific-brainstorming"), + s("Critical Thinking", "scientific-critical-thinking"), + s("Scholar Evaluation", "scholar-evaluation"), + s("Paper to Web", "paper-2-web"), + s("Venue Templates", "venue-templates"), + s("Market Research Reports", "market-research-reports"), + s("Image Generation", "generate-image"), + s("Open Notebook", "open-notebook"), + s("MarkItDown", "markitdown"), + ], + }, + SkillCategory { + id: "multi-omics".into(), + name: "Multi-omics & Systems Biology".into(), + icon: "microscope".into(), + skill_count: 0, + skills: vec![ + s("scvi-tools", "scvi-tools"), + s("COBRApy", "cobrapy"), + s("Bioservices", "bioservices"), + s("Arboreto (GRN)", "arboreto"), + s("Reactome Database", "reactome-database"), + ], + }, + SkillCategory { + id: "engineering".into(), + name: "Engineering & Simulation".into(), + icon: "settings".into(), + skill_count: 0, + skills: vec![ + s("SimPy", "simpy"), + s("pymoo", "pymoo"), + s("FluidSim", "fluidsim"), + s("MATLAB/Octave", "matlab"), + ], + }, + SkillCategory { + id: "proteomics".into(), + name: "Proteomics & Mass Spec".into(), + icon: "atom".into(), + skill_count: 0, + skills: vec![ + s("PyOpenMS", "pyopenms"), + s("matchms", "matchms"), + s("ESM (Protein LM)", "esm"), + s("PDB Database", "pdb-database"), + s("UniProt Database", "uniprot-database"), + s("HMDB Database", "hmdb-database"), + ], + }, + SkillCategory { + id: "healthcare-ai".into(), + name: "Healthcare AI & Clinical ML".into(), + icon: "activity".into(), + skill_count: 0, + skills: vec![ + s("PyHealth", "pyhealth"), + s("NeuroKit2", "neurokit2"), + s("scikit-survival", "scikit-survival"), + s("GWAS Catalog", "gwas-database"), + s("OpenAlex Database", "openalex-database"), + s("PubMed Database", "pubmed-database"), + s("bioRxiv Database", "biorxiv-database"), + s("GEO Database", "geo-database"), + ], + }, + SkillCategory { + id: "medical-imaging".into(), + name: "Medical Imaging".into(), + icon: "scan".into(), + skill_count: 0, + skills: vec![ + s("pydicom", "pydicom"), + s("HistoLab", "histolab"), + s("PathML", "pathml"), + s("Neuropixels Analysis", "neuropixels-analysis"), + s("Imaging Data Commons", "imaging-data-commons"), + s("GeoMaster", "geomaster"), + s("GeoPandas", "geopandas"), + ], + }, + SkillCategory { + id: "materials-science".into(), + name: "Materials Science".into(), + icon: "gem".into(), + skill_count: 0, + skills: vec![ + s("Pymatgen", "pymatgen"), + s("QuTiP", "qutip"), + s("SymPy", "sympy"), + s("Astropy", "astropy"), + s("Open Targets", "opentargets-database"), + ], + }, + SkillCategory { + id: "physics-astronomy".into(), + name: "Physics & Astronomy".into(), + icon: "telescope".into(), + skill_count: 0, + skills: vec![ + s("Astropy", "astropy"), + s("QuTiP", "qutip"), + s("PennyLane", "pennylane"), + s("SymPy", "sympy"), + ], + }, + SkillCategory { + id: "lab-automation".into(), + name: "Laboratory Automation".into(), + icon: "pipette".into(), + skill_count: 0, + skills: vec![ + s("Opentrons", "opentrons-integration"), + s("PyLabRobot", "pylabrobot"), + s("Protocols.io", "protocolsio-integration"), + s("LabArchive", "labarchive-integration"), + s("Ginkgo Cloud Lab", "ginkgo-cloud-lab"), + ], + }, + SkillCategory { + id: "protein-engineering".into(), + name: "Protein Engineering".into(), + icon: "helix".into(), + skill_count: 0, + skills: vec![ + s("AlphaFold Database", "alphafold-database"), + s("ESM (Protein LM)", "esm"), + s("DiffDock", "diffdock"), + s("Adaptyv", "adaptyv"), + s("STRING Database", "string-database"), + s("LaminDB", "lamindb"), + ], + }, + SkillCategory { + id: "research-methodology".into(), + name: "Research Methodology".into(), + icon: "lightbulb".into(), + skill_count: 0, + skills: vec![ + s("Hypothesis Generation", "hypothesis-generation"), + s("Scientific Brainstorming", "scientific-brainstorming"), + s("Critical Thinking", "scientific-critical-thinking"), + s("Experimental Design", "hypothesis-generation"), + s("Scholar Evaluation", "scholar-evaluation"), + s("Peer Review", "peer-review"), + s("Research Lookup", "research-lookup"), + s("Denario", "denario"), + s("bGPT Paper Search", "bgpt-paper-search"), + s("Perplexity Search", "perplexity-search"), + ], + }, + ]; + + for cat in &mut cats { + cat.skill_count = cat.skills.len(); + } + + cats +} + +// ─── Helpers ─── + +/// Resolve the target skills directory. +fn skills_dir(project_path: Option<&str>) -> PathBuf { + match project_path { + Some(p) => PathBuf::from(p).join(".claude").join("skills"), + None => dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude") + .join("skills"), + } +} + +/// Check if git is available on PATH. +async fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Clone the repo using git (shallow clone). +async fn clone_repo(tmp_dir: &Path) -> Result<(), String> { + let output = Command::new("git") + .args(["clone", "--depth", "1", REPO_URL]) + .arg(tmp_dir.join("repo")) + .output() + .await + .map_err(|e| format!("Failed to run git clone: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git clone failed: {}", stderr)); + } + Ok(()) +} + +/// Download and extract tarball as fallback when git is not available. +async fn download_tarball(tmp_dir: &Path) -> Result<(), String> { + let response = reqwest::get(TARBALL_URL) + .await + .map_err(|e| format!("Failed to download tarball: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Tarball download failed with status: {}", + response.status() + )); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read tarball bytes: {}", e))?; + + // Decompress gzip + let decoder = flate2::read::GzDecoder::new(&bytes[..]); + let mut archive = tar::Archive::new(decoder); + + archive + .unpack(tmp_dir.join("repo-raw")) + .map_err(|e| format!("Failed to extract tarball: {}", e))?; + + // The tarball extracts to claude-scientific-skills-main/ + // We need to find it and rename to repo/ + let raw_dir = tmp_dir.join("repo-raw"); + if let Ok(mut entries) = std::fs::read_dir(&raw_dir) { + if let Some(Ok(entry)) = entries.next() { + std::fs::rename(entry.path(), tmp_dir.join("repo")) + .map_err(|e| format!("Failed to rename extracted dir: {}", e))?; + } + } + + // Clean up the raw extraction directory + let _ = std::fs::remove_dir_all(&raw_dir); + + Ok(()) +} + +/// Copy the scientific-skills directory from the cloned repo to the target. +fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result { + let src = repo_dir.join(SKILLS_SUBFOLDER); + if !src.exists() { + return Err(format!( + "scientific-skills directory not found in cloned repo at {}", + src.display() + )); + } + + // Create target directory + std::fs::create_dir_all(target_dir) + .map_err(|e| format!("Failed to create target dir: {}", e))?; + + let mut count = 0; + + // Iterate through skill subdirectories + let entries = std::fs::read_dir(&src) + .map_err(|e| format!("Failed to read skills dir: {}", e))?; + + for entry in entries.flatten() { + let entry_path = entry.path(); + if !entry_path.is_dir() { + continue; + } + + let skill_name = entry + .file_name() + .to_string_lossy() + .to_string(); + + let target_skill = target_dir.join(&skill_name); + copy_dir_recursive(&entry_path, &target_skill)?; + count += 1; + } + + Ok(count) +} + +/// Recursively copy a directory. +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + std::fs::create_dir_all(dst) + .map_err(|e| format!("Failed to create dir {}: {}", dst.display(), e))?; + + let entries = std::fs::read_dir(src) + .map_err(|e| format!("Failed to read dir {}: {}", src.display(), e))?; + + for entry in entries.flatten() { + let entry_path = entry.path(); + let target = dst.join(entry.file_name()); + + if entry_path.is_dir() { + copy_dir_recursive(&entry_path, &target)?; + } else { + std::fs::copy(&entry_path, &target) + .map_err(|e| format!("Failed to copy {}: {}", entry_path.display(), e))?; + } + } + + Ok(()) +} + +/// Parse a SKILL.md file to extract skill info. +fn parse_skill_md(skill_dir: &Path) -> Option { + let skill_md = skill_dir.join("SKILL.md"); + if !skill_md.exists() { + return None; + } + + let content = std::fs::read_to_string(&skill_md).ok()?; + let folder = skill_dir + .file_name()? + .to_string_lossy() + .to_string(); + + // Extract title from first # heading + let name = content + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| l.trim_start_matches("# ").trim().to_string()) + .unwrap_or_else(|| folder.clone()); + + // Extract description from first paragraph after heading + let description = content + .lines() + .skip_while(|l| !l.starts_with("# ")) + .skip(1) + .skip_while(|l| l.trim().is_empty()) + .take_while(|l| !l.trim().is_empty() && !l.starts_with('#')) + .collect::>() + .join(" ") + .chars() + .take(200) + .collect::(); + + // Infer domain from folder name prefix (e.g., "bioinformatics-rna-seq" → "bioinformatics") + let domain = folder + .split('-') + .next() + .unwrap_or("general") + .to_string(); + + Some(SkillInfo { + id: folder.clone(), + name, + domain, + description, + folder, + }) +} + +// ─── Tauri Commands ─── + +#[tauri::command] +pub async fn install_scientific_skills(project_path: String) -> Result { + let target = skills_dir(Some(&project_path)); + install_skills_to(&target, Some(&project_path)).await +} + +#[tauri::command] +pub async fn install_scientific_skills_global() -> Result { + let target = skills_dir(None); + install_skills_to(&target, None).await +} + +/// Core installation logic. +async fn install_skills_to( + target: &Path, + _project_path: Option<&str>, +) -> Result { + // Create a temporary directory for the clone/download + let tmp_dir = std::env::temp_dir().join(format!( + "claude-scientific-skills-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + )); + std::fs::create_dir_all(&tmp_dir) + .map_err(|e| format!("Failed to create temp dir: {}", e))?; + + // Try git clone first, fall back to tarball download + if git_available().await { + if let Err(e) = clone_repo(&tmp_dir).await { + eprintln!("git clone failed, trying tarball: {}", e); + download_tarball(&tmp_dir).await?; + } + } else { + download_tarball(&tmp_dir).await?; + } + + let repo_dir = tmp_dir.join("repo"); + + // Copy skills to target directory + let count = copy_skills(&repo_dir, target)?; + + // Clean up temp directory + let _ = std::fs::remove_dir_all(&tmp_dir); + + let target_str = target.to_string_lossy().to_string(); + + Ok(InstallResult { + success: true, + skills_installed: count, + target_dir: target_str.clone(), + message: format!( + "Successfully installed {} skills to {}", + count, target_str + ), + }) +} + +#[tauri::command] +pub async fn check_skills_installed( + project_path: Option, +) -> Result { + let target = skills_dir(project_path.as_deref()); + + if !target.exists() { + return Ok(SkillsStatus { + installed: false, + skill_count: 0, + location: target.to_string_lossy().to_string(), + }); + } + + // Count subdirectories that contain SKILL.md + let count = std::fs::read_dir(&target) + .map_err(|e| format!("Failed to read skills dir: {}", e))? + .flatten() + .filter(|e| e.path().is_dir() && e.path().join("SKILL.md").exists()) + .count(); + + Ok(SkillsStatus { + installed: count > 0, + skill_count: count, + location: target.to_string_lossy().to_string(), + }) +} + +#[tauri::command] +pub async fn list_installed_skills( + project_path: Option, +) -> Result, String> { + let target = skills_dir(project_path.as_deref()); + + if !target.exists() { + return Ok(Vec::new()); + } + + let mut skills = Vec::new(); + let entries = std::fs::read_dir(&target) + .map_err(|e| format!("Failed to read skills dir: {}", e))?; + + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(info) = parse_skill_md(&entry.path()) { + skills.push(info); + } + } + } + + skills.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(skills) +} + +#[tauri::command] +pub async fn uninstall_scientific_skills( + project_path: Option, +) -> Result<(), String> { + let target = skills_dir(project_path.as_deref()); + + if target.exists() { + std::fs::remove_dir_all(&target) + .map_err(|e| format!("Failed to remove skills: {}", e))?; + } + + Ok(()) +} + +#[tauri::command] +pub fn get_skill_categories() -> Vec { + skill_categories() +} + +/// Read the raw SKILL.md content for a specific skill folder. +/// Tries local install first, then fetches from GitHub. +#[tauri::command] +pub async fn get_skill_content( + skill_folder: String, + project_path: Option, +) -> Result { + // Try local (project-level first, then global) + let locations: Vec = match project_path.as_deref() { + Some(pp) => vec![skills_dir(Some(pp)), skills_dir(None)], + None => vec![skills_dir(None)], + }; + + for base in &locations { + let skill_md = base.join(&skill_folder).join("SKILL.md"); + if skill_md.exists() { + return std::fs::read_to_string(&skill_md) + .map_err(|e| format!("Failed to read SKILL.md: {}", e)); + } + } + + // Fallback: fetch from GitHub + let url = format!( + "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills/{}/SKILL.md", + skill_folder + ); + + let response = reqwest::get(&url) + .await + .map_err(|e| format!("Failed to fetch from GitHub: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Skill '{}' not found (HTTP {})", skill_folder, response.status())); + } + + response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e)) +} + +// ─── Tests ─── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_skills_dir_global() { + let dir = skills_dir(None); + assert!(dir.to_string_lossy().contains(".claude")); + assert!(dir.to_string_lossy().ends_with("skills")); + } + + #[test] + fn test_skills_dir_project() { + let dir = skills_dir(Some("/tmp/my-project")); + assert_eq!( + dir, + PathBuf::from("/tmp/my-project/.claude/skills") + ); + } + + #[test] + fn test_skill_categories_count() { + let cats = skill_categories(); + assert_eq!(cats.len(), 16); + // Verify skill_count matches actual skills vec length + for cat in &cats { + assert_eq!(cat.skill_count, cat.skills.len(), "Mismatch in {}", cat.id); + } + let total: usize = cats.iter().map(|c| c.skill_count).sum(); + assert!(total >= 100); + } + + #[test] + fn test_parse_skill_md() { + let tmp = std::env::temp_dir().join("test-skill-parse"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let skill_content = "# RNA-seq Analysis\n\nComprehensive RNA-seq data analysis pipeline.\n\n## Usage\nUse this skill for RNA sequencing workflows.\n"; + std::fs::write(tmp.join("SKILL.md"), skill_content).unwrap(); + + let info = parse_skill_md(&tmp).unwrap(); + assert_eq!(info.name, "RNA-seq Analysis"); + assert!(info.description.contains("RNA-seq")); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/apps/desktop/src-tauri/src/uv.rs b/apps/desktop/src-tauri/src/uv.rs new file mode 100644 index 0000000..5eb419c --- /dev/null +++ b/apps/desktop/src-tauri/src/uv.rs @@ -0,0 +1,350 @@ +use std::path::PathBuf; +use tauri::{Emitter, WebviewWindow}; +use tokio::io::{AsyncBufReadExt, BufReader}; + +// ─── Binary Discovery ─── + +/// Discover the uv binary on the system. +/// Checks: which → cargo bin → standard paths → bare fallback. +fn find_uv_binary() -> Result { + // 1. Try to find uv on PATH + if let Ok(path) = which::which("uv") { + return Ok(path.to_string_lossy().to_string()); + } + + // 2. Check user-specific paths + if let Some(home) = dirs::home_dir() { + #[cfg(not(target_os = "windows"))] + let user_paths = vec![ + home.join(".cargo").join("bin").join("uv"), + home.join(".local").join("bin").join("uv"), + ]; + #[cfg(target_os = "windows")] + let user_paths = vec![ + home.join(".cargo").join("bin").join("uv.exe"), + // uv's default Windows install location + PathBuf::from( + std::env::var("LOCALAPPDATA").unwrap_or_else(|_| { + home.join("AppData").join("Local").to_string_lossy().to_string() + }), + ) + .join("uv") + .join("bin") + .join("uv.exe"), + ]; + + for path in &user_paths { + if path.exists() { + return Ok(path.to_string_lossy().to_string()); + } + } + } + + // 3. Check standard paths (Unix only) + #[cfg(not(target_os = "windows"))] + { + let standard_paths = [ + "/usr/local/bin/uv", + "/opt/homebrew/bin/uv", + "/usr/bin/uv", + ]; + for path in &standard_paths { + if PathBuf::from(path).exists() { + return Ok(path.to_string()); + } + } + } + + // 4. Bare fallback — hope it's in PATH + Ok("uv".to_string()) +} + +// ─── Status Types ─── + +#[derive(serde::Serialize)] +pub struct UvStatus { + pub installed: bool, + pub binary_path: Option, + pub version: Option, +} + +#[derive(serde::Serialize)] +pub struct VenvInfo { + pub venv_path: String, + pub python_path: String, + pub created: bool, +} + +#[derive(serde::Serialize)] +pub struct UvCommandResult { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +// ─── Helper: build PATH with venv bin prepended ─── + +fn venv_bin_dir(venv_dir: &std::path::Path) -> PathBuf { + #[cfg(not(target_os = "windows"))] + { + venv_dir.join("bin") + } + #[cfg(target_os = "windows")] + { + venv_dir.join("Scripts") + } +} + +fn venv_python(venv_dir: &std::path::Path) -> PathBuf { + #[cfg(not(target_os = "windows"))] + { + venv_bin_dir(venv_dir).join("python") + } + #[cfg(target_os = "windows")] + { + venv_bin_dir(venv_dir).join("python.exe") + } +} + +fn path_with_venv(venv_dir: &std::path::Path) -> String { + let bin = venv_bin_dir(venv_dir); + let current = std::env::var("PATH").unwrap_or_default(); + #[cfg(target_os = "windows")] + let sep = ";"; + #[cfg(not(target_os = "windows"))] + let sep = ":"; + format!("{}{}{}", bin.to_string_lossy(), sep, current) +} + +// ─── Tauri Commands ─── + +#[tauri::command] +pub async fn check_uv_status() -> Result { + let binary_path = match find_uv_binary() { + Ok(path) => path, + Err(_) => { + return Ok(UvStatus { + installed: false, + binary_path: None, + version: None, + }); + } + }; + + // Verify binary actually works by running --version + let version_output = std::process::Command::new(&binary_path) + .arg("--version") + .output(); + + let version = match version_output { + Ok(output) if output.status.success() => { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + _ => { + return Ok(UvStatus { + installed: false, + binary_path: None, + version: None, + }); + } + }; + + Ok(UvStatus { + installed: true, + binary_path: Some(binary_path), + version, + }) +} + +#[tauri::command] +pub async fn install_uv(window: WebviewWindow) -> Result<(), String> { + #[cfg(not(target_os = "windows"))] + let mut cmd = { + let mut c = tokio::process::Command::new("bash"); + c.args(["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"]); + c + }; + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = tokio::process::Command::new("powershell"); + c.args([ + "-NoProfile", + "-Command", + "irm https://astral.sh/uv/install.ps1 | iex", + ]); + c + }; + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + // Inherit essential environment variables + for (key, value) in std::env::vars() { + if key == "PATH" + || key == "HOME" + || key == "USER" + || key == "SHELL" + || key == "LANG" + || key.starts_with("LC_") + || key == "HOMEBREW_PREFIX" + || key == "HOMEBREW_CELLAR" + || key == "HTTP_PROXY" + || key == "HTTPS_PROXY" + || key == "NO_PROXY" + || key == "ALL_PROXY" + { + cmd.env(&key, &value); + } + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to run uv installer: {}", e))?; + + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; + let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; + + let stdout_reader = BufReader::new(stdout); + let stderr_reader = BufReader::new(stderr); + + // Stream stdout + let win_stdout = window.clone(); + let stdout_task = tokio::spawn(async move { + let mut lines = stdout_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = win_stdout.emit("uv-install-output", &line); + } + }); + + // Stream stderr + let win_stderr = window.clone(); + let stderr_task = tokio::spawn(async move { + let mut lines = stderr_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = win_stderr.emit("uv-install-output", &line); + } + }); + + // Wait for completion + let win_complete = window; + tokio::spawn(async move { + let _ = stdout_task.await; + let _ = stderr_task.await; + + let success = match child.wait().await { + Ok(status) => status.success(), + Err(_) => false, + }; + + let _ = win_complete.emit("uv-install-complete", success); + }); + + Ok(()) +} + +#[tauri::command] +pub async fn setup_project_venv(project_path: String) -> Result { + let project = std::path::Path::new(&project_path); + let venv_dir = project.join(".venv"); + + // If venv already exists, just return info + if venv_dir.exists() { + let python = venv_python(&venv_dir); + return Ok(VenvInfo { + venv_path: venv_dir.to_string_lossy().to_string(), + python_path: python.to_string_lossy().to_string(), + created: false, + }); + } + + let uv_bin = find_uv_binary().map_err(|e| format!("uv not found: {}", e))?; + + // Create venv: uv venv /.venv + let output = tokio::process::Command::new(&uv_bin) + .args(["venv", &venv_dir.to_string_lossy()]) + .current_dir(project) + .output() + .await + .map_err(|e| format!("Failed to create venv: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("uv venv failed: {}", stderr)); + } + + let python = venv_python(&venv_dir); + + Ok(VenvInfo { + venv_path: venv_dir.to_string_lossy().to_string(), + python_path: python.to_string_lossy().to_string(), + created: true, + }) +} + +#[tauri::command] +pub async fn uv_add_packages( + packages: Vec, + project_path: String, +) -> Result { + let uv_bin = find_uv_binary().map_err(|e| format!("uv not found: {}", e))?; + let venv_dir = std::path::Path::new(&project_path).join(".venv"); + + if !venv_dir.exists() { + return Err("No .venv found. Run setup_project_venv first.".to_string()); + } + + let mut args = vec!["pip".to_string(), "install".to_string()]; + args.extend(packages); + + let output = tokio::process::Command::new(&uv_bin) + .args(&args) + .current_dir(&project_path) + .env("VIRTUAL_ENV", &venv_dir) + .env("PATH", path_with_venv(&venv_dir)) + .output() + .await + .map_err(|e| format!("Failed to run uv pip install: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("uv pip install failed: {}", stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(stdout) +} + +#[tauri::command] +pub async fn uv_run_command( + command: String, + project_path: String, +) -> Result { + let venv_dir = std::path::Path::new(&project_path).join(".venv"); + + if !venv_dir.exists() { + return Err("No .venv found. Run setup_project_venv first.".to_string()); + } + + // Split command into program + args + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Err("Empty command".to_string()); + } + + let output = tokio::process::Command::new(parts[0]) + .args(&parts[1..]) + .current_dir(&project_path) + .env("VIRTUAL_ENV", &venv_dir) + .env("PATH", path_with_venv(&venv_dir)) + .output() + .await + .map_err(|e| format!("Failed to run command: {}", e))?; + + let exit_code = output.status.code().unwrap_or(-1); + + Ok(UvCommandResult { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code, + }) +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e7e53b6..6b9db03 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -5,13 +5,19 @@ import { useDocumentStore } from "@/stores/document-store"; import { useClaudeChatStore } from "@/stores/claude-chat-store"; import { ProjectPicker } from "@/components/project-picker"; import { WorkspaceLayout } from "@/components/workspace/workspace-layout"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { + ScientificSkillsOnboarding, + shouldShowOnboarding, +} from "@/components/scientific-skills/scientific-skills-onboarding"; +import { useUvSetupStore } from "@/stores/uv-setup-store"; function WorkspaceWithClaude() { const projectRoot = useDocumentStore((s) => s.projectRoot); const initialized = useDocumentStore((s) => s.initialized); + const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false); // Update window title useEffect(() => { @@ -21,6 +27,28 @@ function WorkspaceWithClaude() { } }, [projectRoot]); + // Show scientific skills onboarding on first launch + useEffect(() => { + if (!initialized) return; + if (shouldShowOnboarding()) { + // Small delay so the workspace renders first + const timer = setTimeout(() => setShowSkillsOnboarding(true), 800); + return () => clearTimeout(timer); + } + }, [initialized]); + + // Auto-setup Python venv when project opens + useEffect(() => { + if (!initialized || !projectRoot) return; + const uvStore = useUvSetupStore.getState(); + uvStore.checkStatus().then(() => { + const { status } = useUvSetupStore.getState(); + if (status === "ready") { + uvStore.setupVenv(projectRoot); + } + }); + }, [initialized, projectRoot]); + // Consume pending initial prompt from project wizard useEffect(() => { if (!initialized) return; @@ -38,6 +66,11 @@ function WorkspaceWithClaude() { <> + {showSkillsOnboarding && ( + setShowSkillsOnboarding(false)} + /> + )} ); } diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx index 8360a83..374c0b0 100644 --- a/apps/desktop/src/components/project-picker.tsx +++ b/apps/desktop/src/components/project-picker.tsx @@ -1,4 +1,6 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { FolderOpenIcon, @@ -7,10 +9,17 @@ import { XIcon, FileTextIcon, SparklesIcon, + CheckCircle2Icon, + CircleIcon, + TerminalIcon, + FlaskConicalIcon, + DownloadIcon, + Loader2Icon, } from "lucide-react"; import { useProjectStore } from "@/stores/project-store"; import { useDocumentStore } from "@/stores/document-store"; import { useClaudeSetupStore } from "@/stores/claude-setup-store"; +import { useUvSetupStore } from "@/stores/uv-setup-store"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -21,6 +30,7 @@ import { } from "@/components/ui/dialog"; import { ProjectWizard, type CreationMode } from "./project-wizard"; import { ClaudeSetup } from "./claude-setup"; +import { cn } from "@/lib/utils"; export function ProjectPicker() { const [showModeDialog, setShowModeDialog] = useState(false); @@ -81,7 +91,7 @@ export function ProjectPicker() {

- {!isClaudeReady && } + {!isClaudeReady ? : }
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/scientific-skills/install-progress.tsx b/apps/desktop/src/components/scientific-skills/install-progress.tsx new file mode 100644 index 0000000..993fa7a --- /dev/null +++ b/apps/desktop/src/components/scientific-skills/install-progress.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { Progress } from "@/components/ui/progress"; + +interface StepDef { + label: string; + pct: number; +} + +const STEPS: StepDef[] = [ + { label: "Downloading repository…", pct: 20 }, + { label: "Extracting skills…", pct: 50 }, + { label: "Copying to .claude/skills…", pct: 80 }, + { label: "Finalizing…", pct: 95 }, +]; + +interface InstallProgressProps { + isInstalling: boolean; + isComplete: boolean; + error: string | null; +} + +export function InstallProgress({ + isInstalling, + isComplete, + error, +}: InstallProgressProps) { + const [phase, setPhase] = useState(0); + + useEffect(() => { + if (!isInstalling || isComplete || error) return; + const interval = setInterval(() => { + setPhase((p) => Math.min(p + 1, STEPS.length - 1)); + }, 1500); + return () => clearInterval(interval); + }, [isInstalling, isComplete, error]); + + useEffect(() => { + if (isComplete) setPhase(STEPS.length); + }, [isComplete]); + + const pct = isComplete ? 100 : error ? STEPS[phase]?.pct ?? 0 : STEPS[phase]?.pct ?? 0; + const label = isComplete + ? "Done" + : error + ? STEPS[phase]?.label ?? "" + : STEPS[phase]?.label ?? ""; + + return ( +
+ +
+

{label}

+

+ {pct}% +

+
+
+ ); +} diff --git a/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx b/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx new file mode 100644 index 0000000..661cdb3 --- /dev/null +++ b/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx @@ -0,0 +1,462 @@ +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { + FlaskConicalIcon, + DownloadIcon, + CheckCircle2Icon, + AlertCircleIcon, + RefreshCwIcon, + ExternalLinkIcon, + Trash2Icon, + Loader2Icon, + ChevronLeftIcon, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { + type SkillCategoryData, + type SkillEntryData, + ICON_MAP, +} from "./skill-category-card"; +import { InstallProgress } from "./install-progress"; + +const STORAGE_KEY = "scientific-skills-installed"; + +interface InstallResult { + success: boolean; + skills_installed: number; + target_dir: string; + message: string; +} + +interface SkillsStatus { + installed: boolean; + skill_count: number; + location: string; +} + +interface ScientificSkillsOnboardingProps { + onClose: () => void; +} + +export function ScientificSkillsOnboarding({ + onClose, +}: ScientificSkillsOnboardingProps) { + const [categories, setCategories] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [isInstalling, setIsInstalling] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [installResult, setInstallResult] = useState(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + const [isUninstalling, setIsUninstalling] = useState(false); + + // Check global install status + const checkStatus = useCallback(async () => { + try { + const gs = await invoke("check_skills_installed", { + projectPath: null, + }); + setStatus(gs); + } catch { + setStatus(null); + } + }, []); + + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + useEffect(() => { + invoke("get_skill_categories") + .then((cats) => { + setCategories(cats); + if (cats.length > 0) setSelectedId(cats[0].id); + }) + .catch(console.error); + }, []); + + const totalSkills = categories.reduce((sum, c) => sum + c.skill_count, 0); + const selected = categories.find((c) => c.id === selectedId) ?? null; + const isInstalled = status?.installed ?? false; + + const handleInstall = useCallback(async () => { + setIsInstalling(true); + setError(null); + + try { + const result = await invoke("install_scientific_skills_global"); + setInstallResult(result); + setIsComplete(true); + localStorage.setItem(STORAGE_KEY, "true"); + await checkStatus(); + } catch (e) { + setError(String(e)); + setIsInstalling(false); + } + }, [checkStatus]); + + const handleUninstall = useCallback(async () => { + setIsUninstalling(true); + try { + await invoke("uninstall_scientific_skills", { projectPath: null }); + await checkStatus(); + const gsAfter = await invoke("check_skills_installed", { projectPath: null }); + if (!gsAfter.installed) { + localStorage.removeItem(STORAGE_KEY); + } + } catch (e) { + console.error("Failed to uninstall:", e); + } finally { + setIsUninstalling(false); + } + }, [checkStatus]); + + // ─── Installing / Complete state ─── + if (isInstalling || isComplete) { + return ( + { if (!open && (isComplete || error)) onClose(); }}> + + + + {isComplete ? ( + + ) : ( + + )} + {isComplete ? "Installation Complete" : "Installing Skills"} + + {isComplete && ( + + {installResult?.skills_installed ?? 0} scientific skills are now available. + + )} + + + + + {error && ( +
+ +

{error}

+
+ )} + +
+ {error && ( + + )} + {(isComplete || error) && ( + + )} +
+
+
+ ); + } + + // ─── Browse state — two-column layout ─── + return ( + { if (!open) onClose(); }}> + + {/* Header */} + +
+
+ Scientific Skills + + {totalSkills} AI skills across {categories.length} domains — powered by{" "} + + K-Dense + + + +
+
+ {isInstalled ? ( + <> + + + {status?.skill_count} installed + + + + + ) : ( + + )} +
+
+
+ + {/* Body — sidebar + detail */} +
+ {/* Category sidebar */} + + + {/* Detail panel */} +
+ {selected ? ( + +
+ +
+
+ ) : ( +
+ Select a category +
+ )} +
+
+ + {/* Footer */} +
+

+ {isInstalled ? status?.location : "~/.claude/skills/"} +

+ +
+
+
+ ); +} + +// ─── Category Detail Panel ─── + +function CategoryDetail({ + category, + isInstalled, +}: { + category: SkillCategoryData; + isInstalled: boolean; +}) { + const Icon = ICON_MAP[category.icon] || FlaskConicalIcon; + const [selectedSkill, setSelectedSkill] = useState(null); + const [skillContent, setSkillContent] = useState(null); + const [loadingContent, setLoadingContent] = useState(false); + const [fetchError, setFetchError] = useState(null); + + // Reset when category changes + useEffect(() => { + setSelectedSkill(null); + setSkillContent(null); + setFetchError(null); + }, [category.id]); + + const handleSkillClick = useCallback( + async (skill: SkillEntryData) => { + if (selectedSkill?.folder === skill.folder) { + setSelectedSkill(null); + setSkillContent(null); + setFetchError(null); + return; + } + setSelectedSkill(skill); + setSkillContent(null); + setFetchError(null); + setLoadingContent(true); + try { + const content = await invoke("get_skill_content", { + skillFolder: skill.folder, + projectPath: null, + }); + setSkillContent(content); + } catch (e) { + setFetchError(String(e)); + } finally { + setLoadingContent(false); + } + }, + [selectedSkill], + ); + + // Viewing a specific skill + if (selectedSkill) { + return ( +
+ + +
+
+ +
+
+

{selectedSkill.name}

+

+ {selectedSkill.folder} +

+
+
+ + + + {loadingContent ? ( +
+ + Loading skill content… +
+ ) : fetchError ? ( +
+ +

{fetchError}

+
+ ) : skillContent ? ( +
+ {skillContent} +
+ ) : null} +
+ ); + } + + // Skill list view + return ( +
+ {/* Header */} +
+
+ +
+
+

{category.name}

+
+ + {category.skill_count} skills + + {isInstalled && ( + + + Installed + + )} +
+
+
+ + + +
+

+ Skills +

+
+ {category.skills.map((skill) => ( + + ))} +
+
+
+ ); +} + +// ─── Helper ─── + +export function shouldShowOnboarding(): boolean { + return localStorage.getItem(STORAGE_KEY) !== "true"; +} + +export function resetOnboardingFlag(): void { + localStorage.removeItem(STORAGE_KEY); +} diff --git a/apps/desktop/src/components/scientific-skills/skill-category-card.tsx b/apps/desktop/src/components/scientific-skills/skill-category-card.tsx new file mode 100644 index 0000000..54f9fe3 --- /dev/null +++ b/apps/desktop/src/components/scientific-skills/skill-category-card.tsx @@ -0,0 +1,74 @@ +import { + Dna, + FlaskConical, + HeartPulse, + BarChart3, + Brain, + BookOpen, + Microscope, + Settings, + Atom, + Activity, + Scan, + Gem, + Telescope, + Pipette, + Lightbulb, + type LucideIcon, +} from "lucide-react"; + +export interface SkillEntryData { + name: string; + folder: string; +} + +export interface SkillCategoryData { + id: string; + name: string; + icon: string; + skill_count: number; + skills: SkillEntryData[]; +} + +export const ICON_MAP: Record = { + dna: Dna, + "flask-conical": FlaskConical, + "heart-pulse": HeartPulse, + "bar-chart-3": BarChart3, + brain: Brain, + "book-open": BookOpen, + microscope: Microscope, + settings: Settings, + atom: Atom, + activity: Activity, + scan: Scan, + gem: Gem, + telescope: Telescope, + pipette: Pipette, + helix: Dna, + lightbulb: Lightbulb, +}; + +// Monotone — all categories use the same foreground-derived color. +// The constant is kept so existing look-ups (ACCENT_COLORS[id]) keep working; +// the value is intentionally a single neutral tone that adapts via opacity. +const MONO = "currentColor"; + +export const ACCENT_COLORS: Record = { + bioinformatics: MONO, + cheminformatics: MONO, + clinical: MONO, + "data-analysis": MONO, + "ml-ai": MONO, + "scientific-communication": MONO, + "multi-omics": MONO, + engineering: MONO, + proteomics: MONO, + "healthcare-ai": MONO, + "medical-imaging": MONO, + "materials-science": MONO, + "physics-astronomy": MONO, + "lab-automation": MONO, + "protein-engineering": MONO, + "research-methodology": MONO, +}; diff --git a/apps/desktop/src/components/ui/badge.tsx b/apps/desktop/src/components/ui/badge.tsx new file mode 100644 index 0000000..c08732a --- /dev/null +++ b/apps/desktop/src/components/ui/badge.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground", + secondary: + "border-transparent bg-secondary text-secondary-foreground", + destructive: + "border-transparent bg-destructive text-destructive-foreground", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/desktop/src/components/ui/hover-card.tsx b/apps/desktop/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..42ec1b1 --- /dev/null +++ b/apps/desktop/src/components/ui/hover-card.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { HoverCard as HoverCardPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function HoverCard({ + openDelay = 200, + closeDelay = 100, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function HoverCardTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function HoverCardContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/apps/desktop/src/components/ui/progress.tsx b/apps/desktop/src/components/ui/progress.tsx new file mode 100644 index 0000000..ff37c1d --- /dev/null +++ b/apps/desktop/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { Progress as ProgressPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/apps/desktop/src/components/ui/scroll-area.tsx b/apps/desktop/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..e760ea4 --- /dev/null +++ b/apps/desktop/src/components/ui/scroll-area.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/apps/desktop/src/components/ui/tabs.tsx b/apps/desktop/src/components/ui/tabs.tsx new file mode 100644 index 0000000..74ff1d7 --- /dev/null +++ b/apps/desktop/src/components/ui/tabs.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import { Tabs as TabsPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/desktop/src/components/uv-setup.tsx b/apps/desktop/src/components/uv-setup.tsx new file mode 100644 index 0000000..ad68281 --- /dev/null +++ b/apps/desktop/src/components/uv-setup.tsx @@ -0,0 +1,206 @@ +import { useEffect, useRef } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + CheckCircle2Icon, + AlertCircleIcon, + DownloadIcon, + Loader2Icon, + TerminalIcon, + FolderIcon, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useUvSetupStore } from "@/stores/uv-setup-store"; +import { useDocumentStore } from "@/stores/document-store"; +import { cn } from "@/lib/utils"; + +interface UvSetupDialogProps { + open: boolean; + onClose: () => void; +} + +export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) { + const status = useUvSetupStore((s) => s.status); + const isInstalling = useUvSetupStore((s) => s.isInstalling); + const error = useUvSetupStore((s) => s.error); + const version = useUvSetupStore((s) => s.version); + const venvReady = useUvSetupStore((s) => s.venvReady); + const venvPath = useUvSetupStore((s) => s.venvPath); + const pythonPath = useUvSetupStore((s) => s.pythonPath); + const checkStatus = useUvSetupStore((s) => s.checkStatus); + const install = useUvSetupStore((s) => s.install); + const setupVenv = useUvSetupStore((s) => s.setupVenv); + const _finishInstall = useUvSetupStore((s) => s._finishInstall); + + const projectRoot = useDocumentStore((s) => s.projectRoot); + const hasCheckedRef = useRef(false); + + // Check status when dialog opens + useEffect(() => { + if (open && !hasCheckedRef.current) { + hasCheckedRef.current = true; + checkStatus(); + } + if (!open) { + hasCheckedRef.current = false; + } + }, [open, checkStatus]); + + // Listen for install completion events + useEffect(() => { + const unlistenComplete = listen("uv-install-complete", (event) => { + _finishInstall(event.payload); + }); + + return () => { + unlistenComplete.then((fn) => fn()); + }; + }, [_finishInstall]); + + const handleSetupVenv = async () => { + if (projectRoot) { + await setupVenv(projectRoot); + } + }; + + return ( + !o && onClose()}> + + + + + Python Environment (uv) + + + Manage the Python virtual environment for this project. + + + +
+ {/* uv status */} +
+ +
+
+ {status === "checking" + ? "Checking uv..." + : status === "not-installed" + ? "uv not installed" + : status === "ready" + ? "uv installed" + : "Error"} +
+ {version && ( +
+ {version} +
+ )} + {error && ( +
{error}
+ )} +
+ {status === "not-installed" && !isInstalling && ( + + )} + {isInstalling && ( + + )} +
+ + {/* venv status — only show when uv is ready */} + {status === "ready" && ( +
+
+ +
+
+
+ {venvReady ? "Virtual Environment Active" : "No Virtual Environment"} +
+ {venvPath && ( +
+ {venvPath} +
+ )} + {pythonPath && ( +
+ Python: {pythonPath.split("/").pop() || pythonPath.split("\\").pop()} +
+ )} +
+ {!venvReady && projectRoot && ( + + )} +
+ )} + + {/* Info text */} + {status === "ready" && venvReady && ( +

+ Claude Code will automatically use this environment when running + Python code. Use uv pip install to + add packages. +

+ )} +
+
+
+ ); +} + +function StatusIcon({ + status, + isInstalling, +}: { + status: string; + isInstalling: boolean; +}) { + if (isInstalling || status === "checking") { + return ( +
+ +
+ ); + } + if (status === "ready") { + return ( +
+ +
+ ); + } + if (status === "error") { + return ( +
+ +
+ ); + } + // not-installed + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/components/workspace/editor/image-preview.tsx b/apps/desktop/src/components/workspace/editor/image-preview.tsx index 9210867..679af75 100644 --- a/apps/desktop/src/components/workspace/editor/image-preview.tsx +++ b/apps/desktop/src/components/workspace/editor/image-preview.tsx @@ -1,14 +1,64 @@ - - +import { useEffect, useRef } from "react"; import { ImageIcon } from "lucide-react"; import type { ProjectFile } from "@/stores/document-store"; +const MIN_SCALE = 0.25; +const MAX_SCALE = 4; + interface ImagePreviewProps { file: ProjectFile; scale: number; + onScaleChange?: (scale: number) => void; } -export function ImagePreview({ file, scale }: ImagePreviewProps) { +// scale 1.0 = fit-to-width (CSS handles it, zero flicker). +// Zoom multiplies from the fitted size. + +export function ImagePreview({ file, scale, onScaleChange }: ImagePreviewProps) { + const containerRef = useRef(null); + + // Pinch-to-zoom (Cmd/Ctrl + wheel) + useEffect(() => { + const el = containerRef.current; + if (!el || !onScaleChange) return; + + const handleWheel = (e: WheelEvent) => { + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + const delta = -e.deltaY * 0.001; + onScaleChange(Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale + delta))); + } + }; + + el.addEventListener("wheel", handleWheel, { passive: false }); + return () => el.removeEventListener("wheel", handleWheel); + }, [scale, onScaleChange]); + + // Keyboard zoom (Cmd/Ctrl +/-) — scoped to container + useEffect(() => { + const el = containerRef.current; + if (!el || !onScaleChange) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + + if (e.key === "=" || e.key === "+") { + e.preventDefault(); + onScaleChange(Math.min(MAX_SCALE, scale + 0.25)); + } else if (e.key === "-") { + e.preventDefault(); + onScaleChange(Math.max(MIN_SCALE, scale - 0.25)); + } else if (e.key === "0") { + e.preventDefault(); + onScaleChange(1); + } + }; + + el.addEventListener("keydown", handleKeyDown); + return () => el.removeEventListener("keydown", handleKeyDown); + }, [scale, onScaleChange]); + if (!file.dataUrl) { return (
@@ -19,16 +69,14 @@ export function ImagePreview({ file, scale }: ImagePreviewProps) { } return ( -
-
+
+ {/* Wrapper width = scale * 100% of container → CSS handles fit, no JS needed */} +
{file.name}
diff --git a/apps/desktop/src/components/workspace/editor/latex-editor.tsx b/apps/desktop/src/components/workspace/editor/latex-editor.tsx index 9d78df1..870db44 100644 --- a/apps/desktop/src/components/workspace/editor/latex-editor.tsx +++ b/apps/desktop/src/components/workspace/editor/latex-editor.tsx @@ -93,6 +93,12 @@ export function LatexEditor() { const historyDiffResult = useHistoryStore((s) => s.diffResult); const [imageScale, setImageScale] = useState(1.0); + + // Reset scale when switching files so each file starts at fit-to-width + useEffect(() => { + setImageScale(1.0); + }, [activeFileId]); + const [isSearchOpen, setIsSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [matchCount, setMatchCount] = useState(0); @@ -714,26 +720,20 @@ export function LatexEditor() { useHistoryStore.getState().stopReview(); }, []); - if (activeFile?.type === "pdf") { - return ; - } - - if (!isTextFile && activeFile) { - return ( -
- -
- - -
-
- ); - } + const isPdf = activeFile?.type === "pdf"; + const isImage = !isTextFile && !isPdf && !!activeFile; return (
- - {isSearchOpen && ( + {/* Toolbar — adapts to file type */} + + {/* Text-editor-only panels */} + {!isPdf && !isImage && isSearchOpen && ( )} - {/* History review bar */} - {reviewingSnapshot && ( + {!isPdf && !isImage && reviewingSnapshot && (
@@ -774,64 +773,77 @@ export function LatexEditor() {
)} -
-
- {/* History diff overlay */} - {reviewingSnapshot && historyDiffResult && ( - + {/* Main content area — single wrapper keeps ClaudeChatDrawer stable */} +
+ {/* PDF content */} + {isPdf && activeFile && ( + )} + {/* Image content */} + {isImage && activeFile && ( + + )} + {/* Text editor content */} + {!isPdf && !isImage && ( + <> +
+ {reviewingSnapshot && historyDiffResult && ( + + )} + {toolbarPosition && selectionLabel && !isMergeActiveRef.current && !isSearchOpen && ( + + )} + {activeFileChange && mergeChunkInfo.total > 0 && ( +
+ + ± {mergeChunkInfo.current}/{mergeChunkInfo.total} + +
+ + +
+ + +
+ )} + + )} + {/* Chat drawer — single stable instance across all file types */} - {/* Selection toolbar */} - {toolbarPosition && selectionLabel && !isMergeActiveRef.current && !isSearchOpen && ( - - )} - {/* Floating chunk navigator pill */} - {activeFileChange && mergeChunkInfo.total > 0 && ( -
- - ± {mergeChunkInfo.current}/{mergeChunkInfo.total} - -
- - -
- - -
- )}
- {diagnostics.length > 0 && ( + {/* Text-editor-only bottom panels */} + {!isPdf && !isImage && diagnostics.length > 0 && ( )} - {activeFileChange && ( + {!isPdf && !isImage && activeFileChange && ( c.filePath === activeFile?.relativePath)} @@ -894,26 +906,27 @@ export function LatexEditor() { ); } -// ─── Inline PDF Viewer (for PDF files opened from file tree) ─── +// ─── Inline PDF Content (data loading + MuPDF PdfViewer) ─── -function InlinePdfViewer({ +function InlinePdfContent({ file, - editorView, imageScale, onImageScaleChange, }: { file: ProjectFile; - editorView: React.RefObject; imageScale: number; onImageScaleChange: (scale: number) => void; }) { const [pdfData, setPdfData] = useState(null); const [error, setError] = useState(null); + const [fitted, setFitted] = useState(false); + const wrapperRef = useRef(null); useEffect(() => { let cancelled = false; setPdfData(null); setError(null); + setFitted(false); readFile(file.absolutePath) .then((data) => { @@ -926,27 +939,38 @@ function InlinePdfViewer({ return () => { cancelled = true; }; }, [file.absolutePath]); - return ( -
- -
- {pdfData ? ( - - ) : error ? ( -
- Failed to load PDF: {error} -
- ) : ( -
- Loading PDF... -
- )} - + const handleFirstPageSize = useCallback((pageWidth: number) => { + const containerWidth = wrapperRef.current?.clientWidth; + if (!containerWidth || !onImageScaleChange) return; + const fitScale = (containerWidth - 32) / pageWidth; // 32px padding + onImageScaleChange(Math.max(0.25, Math.min(2, fitScale))); + setFitted(true); + }, [onImageScaleChange]); + + if (pdfData) { + return ( +
+
+ ); + } + + if (error) { + return ( +
+ Failed to load PDF: {error} +
+ ); + } + + return ( +
+ Loading PDF...
); } diff --git a/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx b/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx index 037a54f..e1f16fd 100644 --- a/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx +++ b/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx @@ -30,6 +30,7 @@ interface PdfViewerProps { onTextClick?: (text: string) => void; onSynctexClick?: (page: number, x: number, y: number) => void; onTextSelect?: (selection: PdfTextSelection | null) => void; + onFirstPageSize?: (width: number, height: number) => void; captureMode?: boolean; onCapture?: (result: CaptureResult) => void; onCancelCapture?: () => void; @@ -44,6 +45,7 @@ export function PdfViewer({ onTextClick, onSynctexClick, onTextSelect, + onFirstPageSize, captureMode = false, onCapture, onCancelCapture, @@ -148,6 +150,9 @@ export function PdfViewer({ setPageSizes(sizes); setLoading(false); + if (isFirstLoad.current && sizes.length > 0) { + onFirstPageSize?.(sizes[0].width, sizes[0].height); + } isFirstLoad.current = false; onLoadSuccess?.(count); @@ -361,6 +366,31 @@ export function PdfViewer({ return () => container.removeEventListener("wheel", handleWheel); }, [scale, onScaleChange]); + // Keyboard zoom (Cmd/Ctrl +/-) — scoped to container to avoid affecting other panels + useEffect(() => { + const container = containerRef.current; + if (!container || !onScaleChange) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + + if (e.key === "=" || e.key === "+") { + e.preventDefault(); + onScaleChange(Math.min(4, scale + 0.25)); + } else if (e.key === "-") { + e.preventDefault(); + onScaleChange(Math.max(0.25, scale - 0.25)); + } else if (e.key === "0") { + e.preventDefault(); + onScaleChange(1); + } + }; + + container.addEventListener("keydown", handleKeyDown); + return () => container.removeEventListener("keydown", handleKeyDown); + }, [scale, onScaleChange]); + // Intercept link clicks useEffect(() => { const container = containerRef.current; @@ -548,7 +578,8 @@ export function PdfViewer({ return (
+ {/* Environment section — Python + Skills */} + + {/* Footer */}
ClaudePrism v{APP_VERSION} @@ -981,6 +988,115 @@ function FileTreeNode({ ); } +// ─── Environment Section (Python + Skills) ─── + +interface SkillsStatus { + installed: boolean; + skill_count: number; + location: string; +} + +function EnvironmentSection({ projectPath }: { projectPath: string | null }) { + // ── Python / uv ── + const venvReady = useUvSetupStore((s) => s.venvReady); + const uvStatus = useUvSetupStore((s) => s.status); + const [showUvDialog, setShowUvDialog] = useState(false); + + // ── Scientific Skills ── + const [skillsStatus, setSkillsStatus] = useState(null); + const [showOnboarding, setShowOnboarding] = useState(false); + + const checkSkillsStatus = useCallback(async () => { + try { + const globalStatus = await invoke("check_skills_installed", { + projectPath: null, + }); + if (globalStatus.installed) { + setSkillsStatus(globalStatus); + return; + } + if (projectPath) { + const projectStatus = await invoke("check_skills_installed", { + projectPath, + }); + setSkillsStatus(projectStatus); + } else { + setSkillsStatus(globalStatus); + } + } catch { + // Ignore errors silently + } + }, [projectPath]); + + useEffect(() => { + checkSkillsStatus(); + }, [checkSkillsStatus]); + + // Lazy import onboarding + const [OnboardingComponent, setOnboardingComponent] = useState void; + }> | null>(null); + + useEffect(() => { + if (showOnboarding && !OnboardingComponent) { + import("@/components/scientific-skills/scientific-skills-onboarding").then( + (mod) => setOnboardingComponent(() => mod.ScientificSkillsOnboarding) + ); + } + }, [showOnboarding, OnboardingComponent]); + + const pythonLabel = + venvReady ? "Active" : uvStatus === "not-installed" ? "Not installed" : uvStatus === "ready" ? "No venv" : ""; + const skillsLabel = + skillsStatus?.installed ? `${skillsStatus.skill_count} skills` : "Not installed"; + + return ( + <> +
+
+ + Environment +
+
+ {/* Python / uv row */} + + {/* Scientific Skills row */} + +
+
+ + setShowUvDialog(false)} /> + + {showOnboarding && OnboardingComponent && ( + { + setShowOnboarding(false); + checkSkillsStatus(); + }} + /> + )} + + ); +} + // ─── Draggable wrapper ─── function DraggableItem({ id, type, name, children }: { id: string; type: "file" | "folder"; name: string; children: React.ReactNode }) { diff --git a/apps/desktop/src/components/workspace/zotero-panel.tsx b/apps/desktop/src/components/workspace/zotero-panel.tsx index 08f9566..a6696b2 100644 --- a/apps/desktop/src/components/workspace/zotero-panel.tsx +++ b/apps/desktop/src/components/workspace/zotero-panel.tsx @@ -161,7 +161,7 @@ export function ZoteroHeader() { Zotero @@ -281,7 +281,7 @@ function CollectionRow({
{name} - {isSynced && } + {isSynced && }
{isSynced && (

diff --git a/apps/desktop/src/lib/template-registry.ts b/apps/desktop/src/lib/template-registry.ts index 9f8468a..d9486ab 100644 --- a/apps/desktop/src/lib/template-registry.ts +++ b/apps/desktop/src/lib/template-registry.ts @@ -2912,6 +2912,167 @@ Back issues: \\href{https://cs.stanford.edu/newsletter}{cs.stanford.edu/newslett \\end{multicols} +\\end{document} +`, + }, + { + id: "report-scientific", + name: "Scientific Report", + description: + "Professional scientific report with structured sections, statistical commands, and bibliography", + category: "academic", + subcategory: "reports", + tags: [ + "scientific", + "report", + "research", + "lab", + "experiment", + "data", + "analysis", + "academic", + ], + icon: "FlaskConical", + documentClass: "report", + mainFileName: "main.tex", + accentColor: "#10b981", + hasBibliography: true, + aspectRatio: "3/4", + packages: [ + { name: "amsmath", description: "AMS mathematical typesetting" }, + { name: "graphicx", description: "Enhanced graphics support" }, + { name: "geometry", description: "Page layout customization" }, + { name: "hyperref", description: "Hyperlinks and PDF metadata" }, + { name: "booktabs", description: "Professional table formatting" }, + { name: "natbib", description: "Bibliography management" }, + { name: "xcolor", description: "Color support" }, + { name: "tcolorbox", description: "Colored boxes for highlights" }, + { name: "siunitx", description: "SI units formatting" }, + ], + content: `\\documentclass[11pt]{report} +\\usepackage[utf8]{inputenc} +\\usepackage[T1]{fontenc} +\\usepackage{lmodern} +\\usepackage{amsmath,amssymb} +\\usepackage{graphicx} +\\usepackage[margin=1in]{geometry} +\\usepackage{hyperref} +\\usepackage{booktabs} +\\usepackage[numbers]{natbib} +\\usepackage{xcolor} +\\usepackage{tcolorbox} +\\usepackage{siunitx} +\\usepackage{float} + +% Colors +\\definecolor{sectionblue}{HTML}{1e40af} +\\definecolor{highlightbg}{HTML}{eff6ff} +\\definecolor{notebg}{HTML}{fef3c7} + +% Highlight box +\\newtcolorbox{highlight}{ + colback=highlightbg, + colframe=sectionblue!50, + boxrule=0.5pt, + arc=3pt, + left=6pt, right=6pt, top=6pt, bottom=6pt +} + +% Note box +\\newtcolorbox{note}{ + colback=notebg, + colframe=orange!50, + boxrule=0.5pt, + arc=3pt, + left=6pt, right=6pt, top=6pt, bottom=6pt +} + +\\hypersetup{ + colorlinks=true, + linkcolor=sectionblue, + citecolor=sectionblue, + urlcolor=sectionblue +} + +\\title{\\textbf{Scientific Report Title}\\\\[0.5em] +\\large Subtitle or Project Name} +\\author{Author Name\\\\ +\\small Institution or Laboratory\\\\ +\\small \\href{mailto:author@institution.edu}{author@institution.edu}} +\\date{\\today} + +\\begin{document} + +\\maketitle + +\\begin{abstract} +Provide a concise summary of the research objectives, methodology, key findings, and conclusions. The abstract should be self-contained and typically 150--300 words. +\\end{abstract} + +\\tableofcontents + +\\chapter{Introduction} + +Describe the background, motivation, and objectives of the study. Include relevant literature context and clearly state the research questions or hypotheses. + +\\begin{highlight} +\\textbf{Research Question:} State your primary research question or hypothesis here. +\\end{highlight} + +\\chapter{Materials and Methods} + +\\section{Experimental Design} + +Describe the overall experimental approach, including study design, variables, and controls. + +\\section{Data Collection} + +Detail the instruments, protocols, and procedures used for data collection. + +\\section{Statistical Analysis} + +All analyses were performed using standard statistical methods. Results were considered significant at $p < 0.05$. + +\\begin{note} +\\textbf{Note:} Include details about software versions, packages, and specific parameters used in the analysis. +\\end{note} + +\\chapter{Results} + +\\section{Descriptive Statistics} + +Present summary statistics and initial findings. + +\\begin{table}[H] +\\centering +\\caption{Summary statistics of key variables.} +\\label{tab:summary} +\\begin{tabular}{lrrr} +\\toprule +\\textbf{Variable} & \\textbf{Mean} & \\textbf{SD} & \\textbf{N} \\\\ +\\midrule +Variable A & \\num{42.5} & \\num{3.2} & 100 \\\\ +Variable B & \\num{18.7} & \\num{2.1} & 100 \\\\ +Variable C & \\num{7.3} & \\num{1.8} & 100 \\\\ +\\bottomrule +\\end{tabular} +\\end{table} + +\\section{Main Findings} + +Present the main results with appropriate figures and tables. Reference Table~\\ref{tab:summary} and any figures as needed. + +\\chapter{Discussion} + +Interpret the results in the context of existing literature. Discuss implications, limitations, and future directions. + +\\chapter{Conclusion} + +Summarize the key findings and their significance. State the main contributions of this work. + +\\bibliographystyle{plainnat} +\\bibliography{references} + \\end{document} `, }, diff --git a/apps/desktop/src/stores/document-store.ts b/apps/desktop/src/stores/document-store.ts index 625e907..a198002 100644 --- a/apps/desktop/src/stores/document-store.ts +++ b/apps/desktop/src/stores/document-store.ts @@ -4,7 +4,6 @@ import { readTexFileContent, writeTexFileContent, readImageAsDataUrl, - getAssetUrl, createFileOnDisk, copyFileToProject, deleteFileFromDisk, @@ -149,10 +148,7 @@ export const useDocumentStore = create()((set, get) => ({ } } - // Load asset URL for PDF files - if (f.type === "pdf") { - pf.dataUrl = getAssetUrl(f.absolutePath); - } + // PDF files are loaded on-demand via readFile in InlinePdfContent projectFiles.push(pf); } @@ -548,9 +544,8 @@ export const useDocumentStore = create()((set, get) => ({ try { pf.dataUrl = await readImageAsDataUrl(pf.absolutePath); } catch { /* skip unreadable */ } - } else if (pf.type === "pdf") { - pf.dataUrl = getAssetUrl(pf.absolutePath); } + // PDF files are loaded on-demand via readFile in InlinePdfContent merged.push(pf); } } diff --git a/apps/desktop/src/stores/uv-setup-store.ts b/apps/desktop/src/stores/uv-setup-store.ts new file mode 100644 index 0000000..d23472d --- /dev/null +++ b/apps/desktop/src/stores/uv-setup-store.ts @@ -0,0 +1,124 @@ +import { create } from "zustand"; +import { invoke } from "@tauri-apps/api/core"; + +// ─── Types ─── + +interface UvStatus { + installed: boolean; + binary_path: string | null; + version: string | null; +} + +interface VenvInfo { + venv_path: string; + python_path: string; + created: boolean; +} + +type UvSetupStatus = "checking" | "not-installed" | "ready" | "error"; + +interface UvSetupState { + status: UvSetupStatus; + isInstalling: boolean; + error: string | null; + version: string | null; + binaryPath: string | null; + + // venv state + venvReady: boolean; + venvPath: string | null; + pythonPath: string | null; + + // Actions + checkStatus: () => Promise; + install: () => Promise; + setupVenv: (projectPath: string) => Promise; + + // Internal + _finishInstall: (success: boolean) => void; +} + +// ─── Store ─── + +export const useUvSetupStore = create((set, get) => ({ + status: "checking", + isInstalling: false, + error: null, + version: null, + binaryPath: null, + + venvReady: false, + venvPath: null, + pythonPath: null, + + checkStatus: async () => { + set({ status: "checking", error: null }); + try { + const result = await invoke("check_uv_status"); + + if (!result.installed) { + set({ status: "not-installed", version: null, binaryPath: null }); + return; + } + + set({ + status: "ready", + version: result.version, + binaryPath: result.binary_path, + }); + } catch (err: any) { + set({ + status: "error", + error: err?.message || String(err), + }); + } + }, + + install: async () => { + set({ isInstalling: true, error: null }); + try { + await invoke("install_uv"); + // Completion is driven by the "uv-install-complete" event + } catch (err: any) { + set({ + isInstalling: false, + status: "error", + error: err?.message || String(err), + }); + } + }, + + setupVenv: async (projectPath: string) => { + try { + const info = await invoke("setup_project_venv", { + projectPath, + }); + set({ + venvReady: true, + venvPath: info.venv_path, + pythonPath: info.python_path, + }); + } catch (err: any) { + console.error("[uv] Failed to setup venv:", err); + // Don't set error status — uv itself is fine, just venv creation failed + set({ + venvReady: false, + venvPath: null, + pythonPath: null, + }); + } + }, + + _finishInstall: (success: boolean) => { + if (success) { + set({ isInstalling: false }); + get().checkStatus(); + } else { + set({ + isInstalling: false, + status: "error", + error: "uv installation failed. Check your internet connection and try again.", + }); + } + }, +}));