feat: add Python environment setup dialog and integrate uv installation process

- Implemented UvSetupDialog component for managing Python virtual environments.
- Added state management for uv installation status using zustand.
- Integrated event listeners for installation completion and status checking.
- Enhanced ImagePreview component with pinch-to-zoom and keyboard shortcuts for scaling.
- Updated LatexEditor to reset image scale when switching files.
- Created EnvironmentSection in Sidebar for displaying Python and skills status.
- Added scientific report template to template registry.
- Refactored PDF loading to improve performance and user experience.
This commit is contained in:
delibae 2026-03-03 17:13:55 +09:00
parent 98a7b4f9f5
commit 30b275e643
25 changed files with 3051 additions and 131 deletions

View file

@ -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"

View file

@ -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"

View file

@ -150,20 +150,34 @@ fn create_command(program: &str, args: Vec<String>, 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<String> {
"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(),
]
}

View file

@ -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");

View file

@ -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<SkillEntry>,
}
// ─── Skill Categories Data ───
/// Returns the known scientific skill categories with metadata.
fn skill_categories() -> Vec<SkillCategory> {
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<usize, String> {
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<SkillInfo> {
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::<Vec<_>>()
.join(" ")
.chars()
.take(200)
.collect::<String>();
// 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<InstallResult, String> {
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<InstallResult, String> {
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<InstallResult, String> {
// 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<String>,
) -> Result<SkillsStatus, String> {
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<String>,
) -> Result<Vec<SkillInfo>, 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<String>,
) -> 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<SkillCategory> {
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<String>,
) -> Result<String, String> {
// Try local (project-level first, then global)
let locations: Vec<PathBuf> = 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);
}
}

View file

@ -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<String, String> {
// 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<String>,
pub version: Option<String>,
}
#[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<UvStatus, String> {
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<VenvInfo, String> {
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 <project_path>/.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<String>,
project_path: String,
) -> Result<String, String> {
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<UvCommandResult, String> {
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,
})
}

View file

@ -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() {
<>
<WorkspaceLayout />
<Toaster />
{showSkillsOnboarding && (
<ScientificSkillsOnboarding
onClose={() => setShowSkillsOnboarding(false)}
/>
)}
</>
);
}

View file

@ -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() {
</p>
</div>
{!isClaudeReady && <ClaudeSetup />}
{!isClaudeReady ? <ClaudeSetup /> : <EnvironmentStatus />}
<div className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}>
<Button
@ -194,3 +204,177 @@ export function ProjectPicker() {
</div>
);
}
// ─── Environment Status (shown when Claude is ready) ───
interface SkillsStatus {
installed: boolean;
skill_count: number;
location: string;
}
function EnvironmentStatus() {
const claudeVersion = useClaudeSetupStore((s) => s.version);
const claudeEmail = useClaudeSetupStore((s) => s.accountEmail);
const uvStatus = useUvSetupStore((s) => s.status);
const uvVersion = useUvSetupStore((s) => s.version);
const uvInstalling = useUvSetupStore((s) => s.isInstalling);
const checkUv = useUvSetupStore((s) => s.checkStatus);
const installUv = useUvSetupStore((s) => s.install);
const _finishUvInstall = useUvSetupStore((s) => s._finishInstall);
const [skillsStatus, setSkillsStatus] = useState<SkillsStatus | null>(null);
const [skillsInstalling, setSkillsInstalling] = useState(false);
const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
const checkSkills = useCallback(async () => {
try {
const gs = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
setSkillsStatus(gs);
} catch {
// ignore
}
}, []);
useEffect(() => {
checkUv();
checkSkills();
}, [checkUv, checkSkills]);
// Listen for uv install completion
useEffect(() => {
const unlisten = listen<boolean>("uv-install-complete", (event) => {
_finishUvInstall(event.payload);
});
return () => {
unlisten.then((fn) => fn());
};
}, [_finishUvInstall]);
// Lazy load skills onboarding
const [OnboardingComponent, setOnboardingComponent] = useState<React.ComponentType<{
onClose: () => void;
}> | null>(null);
useEffect(() => {
if (showSkillsOnboarding && !OnboardingComponent) {
import("@/components/scientific-skills/scientific-skills-onboarding").then(
(mod) => setOnboardingComponent(() => mod.ScientificSkillsOnboarding)
);
}
}, [showSkillsOnboarding, OnboardingComponent]);
return (
<>
<div className="flex w-full flex-col rounded-xl border border-border bg-muted/30 px-4 py-3 gap-2">
{/* Claude Code — always ready here */}
<StatusRow
ok={true}
label="Claude Code"
detail={[claudeVersion, claudeEmail].filter(Boolean).join(" · ")}
/>
{/* Python (uv) */}
<StatusRow
ok={uvStatus === "ready"}
label="Python (uv)"
detail={
uvInstalling
? "Installing..."
: uvStatus === "ready"
? uvVersion ?? "Installed"
: uvStatus === "checking"
? "Checking..."
: "Not installed"
}
action={
uvStatus === "not-installed" && !uvInstalling
? { label: "Install", onClick: installUv }
: uvInstalling
? { label: "Installing...", loading: true }
: undefined
}
/>
{/* Scientific Skills */}
<StatusRow
ok={!!skillsStatus?.installed}
label="Scientific Skills"
detail={
skillsInstalling
? "Installing..."
: skillsStatus?.installed
? `${skillsStatus.skill_count} skills`
: "Not installed"
}
action={
!skillsStatus?.installed && !skillsInstalling
? { label: "Install", onClick: () => setShowSkillsOnboarding(true) }
: undefined
}
/>
</div>
{showSkillsOnboarding && OnboardingComponent && (
<OnboardingComponent
onClose={() => {
setShowSkillsOnboarding(false);
checkSkills();
}}
/>
)}
</>
);
}
function StatusRow({
ok,
label,
detail,
action,
}: {
ok: boolean;
label: string;
detail: string;
action?: { label: string; onClick?: () => void; loading?: boolean };
}) {
return (
<div className="flex items-center gap-2.5 min-w-0">
{ok ? (
<CheckCircle2Icon className="size-3.5 shrink-0 text-foreground" />
) : (
<CircleIcon className="size-3.5 shrink-0 text-muted-foreground/40" />
)}
<span
className={cn(
"text-sm shrink-0",
ok ? "text-foreground" : "text-muted-foreground"
)}
>
{label}
</span>
<span className="text-xs text-muted-foreground truncate min-w-0 flex-1">
{detail}
</span>
{action && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs shrink-0"
onClick={action.onClick}
disabled={action.loading}
>
{action.loading ? (
<Loader2Icon className="mr-1 size-3 animate-spin" />
) : (
<DownloadIcon className="mr-1 size-3" />
)}
{action.label}
</Button>
)}
</div>
);
}

View file

@ -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 (
<div className="space-y-2 py-1">
<Progress value={pct} />
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs">{label}</p>
<p className="font-mono text-muted-foreground text-xs tabular-nums">
{pct}%
</p>
</div>
</div>
);
}

View file

@ -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<SkillCategoryData[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [installResult, setInstallResult] = useState<InstallResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<SkillsStatus | null>(null);
const [isUninstalling, setIsUninstalling] = useState(false);
// Check global install status
const checkStatus = useCallback(async () => {
try {
const gs = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
setStatus(gs);
} catch {
setStatus(null);
}
}, []);
useEffect(() => {
checkStatus();
}, [checkStatus]);
useEffect(() => {
invoke<SkillCategoryData[]>("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<InstallResult>("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<SkillsStatus>("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 (
<Dialog open onOpenChange={(open) => { if (!open && (isComplete || error)) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
{isComplete ? (
<CheckCircle2Icon className="size-5 text-foreground" />
) : (
<FlaskConicalIcon className="size-5 text-muted-foreground" />
)}
{isComplete ? "Installation Complete" : "Installing Skills"}
</DialogTitle>
{isComplete && (
<DialogDescription>
{installResult?.skills_installed ?? 0} scientific skills are now available.
</DialogDescription>
)}
</DialogHeader>
<InstallProgress
isInstalling={isInstalling}
isComplete={isComplete}
error={error}
/>
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-xs leading-relaxed text-muted-foreground">{error}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
{error && (
<Button
variant="outline"
size="sm"
onClick={() => { setError(null); setIsInstalling(false); }}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
Retry
</Button>
)}
{(isComplete || error) && (
<Button size="sm" onClick={onClose}>
{isComplete ? "Done" : "Close"}
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Browse state — two-column layout ───
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent
showCloseButton={false}
className="flex max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none w-[min(56rem,calc(100vw-4rem))] h-[min(36rem,calc(100vh-6rem))]"
>
{/* Header */}
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">Scientific Skills</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
{totalSkills} AI skills across {categories.length} domains powered by{" "}
<a
href="https://github.com/K-Dense-AI/claude-scientific-skills"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
>
K-Dense
<ExternalLinkIcon className="size-2.5" />
</a>
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-3">
{isInstalled ? (
<>
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle2Icon className="size-3" />
{status?.skill_count} installed
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleInstall}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
Update
</Button>
<Button
variant="outline"
size="sm"
onClick={handleUninstall}
disabled={isUninstalling}
className="gap-1.5 text-destructive hover:text-destructive"
>
{isUninstalling ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Uninstall
</Button>
</>
) : (
<Button size="sm" onClick={handleInstall} className="gap-1.5">
<DownloadIcon className="size-3.5" />
Install All
</Button>
)}
</div>
</div>
</DialogHeader>
{/* Body — sidebar + detail */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<nav className="w-64 shrink-0 overflow-hidden border-r border-border">
<ScrollArea className="h-full">
<div className="flex flex-col gap-0.5 p-2">
{categories.map((cat) => {
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
const isActive = selectedId === cat.id;
return (
<button
key={cat.id}
onClick={() => setSelectedId(cat.id)}
className={cn(
"flex items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{cat.name}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{cat.skill_count}
</span>
</button>
);
})}
</div>
</ScrollArea>
</nav>
{/* Detail panel */}
<div className="flex flex-1 flex-col overflow-hidden">
{selected ? (
<ScrollArea className="flex-1">
<div className="p-6">
<CategoryDetail
category={selected}
isInstalled={isInstalled}
/>
</div>
</ScrollArea>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
Select a category
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/20 px-6 py-2.5">
<p className="font-mono text-muted-foreground/60 text-[11px]">
{isInstalled ? status?.location : "~/.claude/skills/"}
</p>
<Button variant="ghost" size="sm" onClick={onClose} className="text-muted-foreground">
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Category Detail Panel ───
function CategoryDetail({
category,
isInstalled,
}: {
category: SkillCategoryData;
isInstalled: boolean;
}) {
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(null);
const [skillContent, setSkillContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(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<string>("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 (
<div>
<button
onClick={() => { setSelectedSkill(null); setSkillContent(null); setFetchError(null); }}
className="mb-3 flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronLeftIcon className="size-3.5" />
{category.name}
</button>
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Icon className="size-5 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm">{selectedSkill.name}</h3>
<p className="mt-0.5 font-mono text-[11px] text-muted-foreground/60">
{selectedSkill.folder}
</p>
</div>
</div>
<Separator className="my-4" />
{loadingContent ? (
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
Loading skill content
</div>
) : fetchError ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-xs leading-relaxed text-muted-foreground">{fetchError}</p>
</div>
) : skillContent ? (
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-xs leading-relaxed text-foreground/80">
{skillContent}
</div>
) : null}
</div>
);
}
// Skill list view
return (
<div>
{/* Header */}
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Icon className="size-5 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm">{category.name}</h3>
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{category.skill_count} skills
</Badge>
{isInstalled && (
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle2Icon className="size-3" />
Installed
</Badge>
)}
</div>
</div>
</div>
<Separator className="my-4" />
<div>
<h4 className="mb-2 font-medium text-muted-foreground text-xs uppercase tracking-wider">
Skills
</h4>
<div className="grid grid-cols-2 gap-1.5">
{category.skills.map((skill) => (
<button
key={skill.folder}
onClick={() => handleSkillClick(skill)}
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/30 px-3 py-2 text-left text-sm transition-colors hover:border-border hover:bg-accent/30"
>
<span className="size-1.5 shrink-0 rounded-full bg-foreground/40" />
{skill.name}
</button>
))}
</div>
</div>
</div>
);
}
// ─── Helper ───
export function shouldShowOnboarding(): boolean {
return localStorage.getItem(STORAGE_KEY) !== "true";
}
export function resetOnboardingFlag(): void {
localStorage.removeItem(STORAGE_KEY);
}

View file

@ -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<string, LucideIcon> = {
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<string, string> = {
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,
};

View file

@ -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<typeof badgeVariants>) {
return (
<span
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View file

@ -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<typeof HoverCardPrimitive.Root>) {
return (
<HoverCardPrimitive.Root
data-slot="hover-card"
openDelay={openDelay}
closeDelay={closeDelay}
{...props}
/>
);
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-56 origin-(--radix-hover-card-content-transform-origin) rounded-lg border bg-popover p-3 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View file

@ -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<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="h-full w-full flex-1 rounded-full bg-primary transition-transform duration-500 ease-out"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View file

@ -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<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Scrollbar>) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-px",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-px",
className,
)}
{...props}
>
<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
);
}
export { ScrollArea, ScrollBar };

View file

@ -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<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -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<boolean>("uv-install-complete", (event) => {
_finishInstall(event.payload);
});
return () => {
unlistenComplete.then((fn) => fn());
};
}, [_finishInstall]);
const handleSetupVenv = async () => {
if (projectRoot) {
await setupVenv(projectRoot);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TerminalIcon className="size-5" />
Python Environment (uv)
</DialogTitle>
<DialogDescription>
Manage the Python virtual environment for this project.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* uv status */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<StatusIcon status={status} isInstalling={isInstalling} />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">
{status === "checking"
? "Checking uv..."
: status === "not-installed"
? "uv not installed"
: status === "ready"
? "uv installed"
: "Error"}
</div>
{version && (
<div className="text-muted-foreground text-xs truncate">
{version}
</div>
)}
{error && (
<div className="text-destructive text-xs mt-1">{error}</div>
)}
</div>
{status === "not-installed" && !isInstalling && (
<Button size="sm" onClick={install}>
<DownloadIcon className="mr-1.5 size-3.5" />
Install
</Button>
)}
{isInstalling && (
<Button size="sm" disabled>
<Loader2Icon className="mr-1.5 size-3.5 animate-spin" />
Installing...
</Button>
)}
</div>
{/* venv status — only show when uv is ready */}
{status === "ready" && (
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={cn(
"flex size-8 items-center justify-center rounded-full",
venvReady
? "bg-accent text-accent-foreground"
: "bg-muted text-muted-foreground"
)}
>
<FolderIcon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">
{venvReady ? "Virtual Environment Active" : "No Virtual Environment"}
</div>
{venvPath && (
<div className="text-muted-foreground text-xs truncate" title={venvPath}>
{venvPath}
</div>
)}
{pythonPath && (
<div className="text-muted-foreground text-xs truncate" title={pythonPath}>
Python: {pythonPath.split("/").pop() || pythonPath.split("\\").pop()}
</div>
)}
</div>
{!venvReady && projectRoot && (
<Button size="sm" variant="outline" onClick={handleSetupVenv}>
Setup .venv
</Button>
)}
</div>
)}
{/* Info text */}
{status === "ready" && venvReady && (
<p className="text-muted-foreground text-xs leading-relaxed">
Claude Code will automatically use this environment when running
Python code. Use <code className="text-foreground">uv pip install</code> to
add packages.
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
function StatusIcon({
status,
isInstalling,
}: {
status: string;
isInstalling: boolean;
}) {
if (isInstalling || status === "checking") {
return (
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
if (status === "ready") {
return (
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-accent-foreground">
<CheckCircle2Icon className="size-4" />
</div>
);
}
if (status === "error") {
return (
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertCircleIcon className="size-4" />
</div>
);
}
// not-installed
return (
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<TerminalIcon className="size-4" />
</div>
);
}

View file

@ -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<HTMLDivElement>(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 (
<div className="flex h-full flex-col items-center justify-center bg-muted/30 p-8">
@ -19,16 +69,14 @@ export function ImagePreview({ file, scale }: ImagePreviewProps) {
}
return (
<div className="h-full overflow-auto bg-muted/50 p-4">
<div className="flex justify-center">
<div ref={containerRef} tabIndex={-1} className="h-full overflow-auto bg-muted/50 p-4 outline-none">
{/* Wrapper width = scale * 100% of container → CSS handles fit, no JS needed */}
<div style={{ width: `${scale * 100}%`, margin: "0 auto" }}>
<img
src={file.dataUrl}
alt={file.name}
style={{
transform: `scale(${scale})`,
transformOrigin: "top center",
}}
className="max-w-none transition-transform"
style={{ width: "100%", height: "auto" }}
draggable={false}
/>
</div>
</div>

View file

@ -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 <InlinePdfViewer file={activeFile} editorView={viewRef} imageScale={imageScale} onImageScaleChange={setImageScale} />;
}
if (!isTextFile && activeFile) {
return (
<div className="flex h-full flex-col bg-background">
<EditorToolbar editorView={viewRef} fileType="image" imageScale={imageScale} onImageScaleChange={setImageScale} />
<div className="relative min-h-0 flex-1 overflow-hidden">
<ImagePreview file={activeFile} scale={imageScale} />
<ClaudeChatDrawer />
</div>
</div>
);
}
const isPdf = activeFile?.type === "pdf";
const isImage = !isTextFile && !isPdf && !!activeFile;
return (
<div className="flex h-full flex-col bg-background">
<EditorToolbar editorView={viewRef} />
{isSearchOpen && (
{/* Toolbar — adapts to file type */}
<EditorToolbar
editorView={viewRef}
fileType={isPdf || isImage ? "image" : undefined}
imageScale={isPdf || isImage ? imageScale : undefined}
onImageScaleChange={isPdf || isImage ? setImageScale : undefined}
/>
{/* Text-editor-only panels */}
{!isPdf && !isImage && isSearchOpen && (
<SearchPanel
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
@ -744,8 +744,7 @@ export function LatexEditor() {
currentMatch={currentMatch}
/>
)}
{/* History review bar */}
{reviewingSnapshot && (
{!isPdf && !isImage && reviewingSnapshot && (
<div className="flex h-9 shrink-0 items-center justify-between border-b border-border bg-amber-500/10 px-3">
<div className="flex items-center gap-2 text-xs">
<RotateCcwIcon className="size-3.5 text-amber-600 dark:text-amber-400" />
@ -774,64 +773,77 @@ export function LatexEditor() {
</div>
</div>
)}
<div ref={parentRef} className="relative min-h-0 flex-1 overflow-hidden">
<div ref={containerRef} className={reviewingSnapshot ? "hidden" : "absolute inset-0"} />
{/* History diff overlay */}
{reviewingSnapshot && historyDiffResult && (
<HistoryDiffView diffs={historyDiffResult} />
{/* Main content area — single wrapper keeps ClaudeChatDrawer stable */}
<div ref={isPdf || isImage ? undefined : parentRef} className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{/* PDF content */}
{isPdf && activeFile && (
<InlinePdfContent file={activeFile} imageScale={imageScale} onImageScaleChange={setImageScale} />
)}
{/* Image content */}
{isImage && activeFile && (
<ImagePreview file={activeFile} scale={imageScale} onScaleChange={setImageScale} />
)}
{/* Text editor content */}
{!isPdf && !isImage && (
<>
<div ref={containerRef} className={reviewingSnapshot ? "hidden" : "absolute inset-0"} />
{reviewingSnapshot && historyDiffResult && (
<HistoryDiffView diffs={historyDiffResult} />
)}
{toolbarPosition && selectionLabel && !isMergeActiveRef.current && !isSearchOpen && (
<SelectionToolbar
position={toolbarPosition}
contextLabel={selectionLabel}
actions={editorToolbarActions}
onSendPrompt={handleToolbarSendPrompt}
onAction={handleToolbarAction}
onDismiss={handleToolbarDismiss}
/>
)}
{activeFileChange && mergeChunkInfo.total > 0 && (
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 rounded-lg border border-border bg-background/95 px-2 py-1 shadow-lg backdrop-blur-sm">
<span className="px-1 font-mono text-xs text-muted-foreground">
±&nbsp;{mergeChunkInfo.current}/{mergeChunkInfo.total}
</span>
<div className="mx-0.5 h-4 w-px bg-border" />
<button
onClick={() => goToChunk(mergeChunkInfo.current <= 1 ? mergeChunkInfo.total - 1 : mergeChunkInfo.current - 2)}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
title="Previous change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button
onClick={() => goToChunk(mergeChunkInfo.current >= mergeChunkInfo.total ? 0 : mergeChunkInfo.current)}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
title="Next change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div className="mx-0.5 h-4 w-px bg-border" />
<button
onClick={acceptCurrentChunk}
className="rounded p-0.5 text-green-400 hover:bg-green-600/20 transition-colors"
title="Accept this change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button
onClick={rejectCurrentChunk}
className="rounded p-0.5 text-red-400 hover:bg-red-600/20 transition-colors"
title="Reject this change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
)}
</>
)}
{/* Chat drawer — single stable instance across all file types */}
<ClaudeChatDrawer />
{/* Selection toolbar */}
{toolbarPosition && selectionLabel && !isMergeActiveRef.current && !isSearchOpen && (
<SelectionToolbar
position={toolbarPosition}
contextLabel={selectionLabel}
actions={editorToolbarActions}
onSendPrompt={handleToolbarSendPrompt}
onAction={handleToolbarAction}
onDismiss={handleToolbarDismiss}
/>
)}
{/* Floating chunk navigator pill */}
{activeFileChange && mergeChunkInfo.total > 0 && (
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 rounded-lg border border-border bg-background/95 px-2 py-1 shadow-lg backdrop-blur-sm">
<span className="px-1 font-mono text-xs text-muted-foreground">
±&nbsp;{mergeChunkInfo.current}/{mergeChunkInfo.total}
</span>
<div className="mx-0.5 h-4 w-px bg-border" />
<button
onClick={() => goToChunk(mergeChunkInfo.current <= 1 ? mergeChunkInfo.total - 1 : mergeChunkInfo.current - 2)}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
title="Previous change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button
onClick={() => goToChunk(mergeChunkInfo.current >= mergeChunkInfo.total ? 0 : mergeChunkInfo.current)}
className="rounded p-0.5 text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
title="Next change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div className="mx-0.5 h-4 w-px bg-border" />
<button
onClick={acceptCurrentChunk}
className="rounded p-0.5 text-green-400 hover:bg-green-600/20 transition-colors"
title="Accept this change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button
onClick={rejectCurrentChunk}
className="rounded p-0.5 text-red-400 hover:bg-red-600/20 transition-colors"
title="Reject this change"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
)}
</div>
{diagnostics.length > 0 && (
{/* Text-editor-only bottom panels */}
{!isPdf && !isImage && diagnostics.length > 0 && (
<ProblemsPanel
diagnostics={diagnostics}
fileName={activeFile?.relativePath ?? "document.tex"}
@ -860,7 +872,7 @@ export function LatexEditor() {
}}
/>
)}
{activeFileChange && (
{!isPdf && !isImage && activeFileChange && (
<ProposedChangesPanel
change={activeFileChange}
changeIndex={proposedChanges.findIndex((c) => 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<EditorView | null>;
imageScale: number;
onImageScaleChange: (scale: number) => void;
}) {
const [pdfData, setPdfData] = useState<Uint8Array | null>(null);
const [error, setError] = useState<string | null>(null);
const [fitted, setFitted] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(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 (
<div className="flex h-full flex-col bg-background">
<EditorToolbar editorView={editorView} fileType="image" imageScale={imageScale} onImageScaleChange={onImageScaleChange} />
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{pdfData ? (
<PdfViewer
data={pdfData}
scale={imageScale}
onScaleChange={onImageScaleChange}
/>
) : error ? (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Failed to load PDF: {error}
</div>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading PDF...
</div>
)}
<ClaudeChatDrawer />
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 (
<div ref={wrapperRef} className="flex min-h-0 flex-1 flex-col" style={{ opacity: fitted ? 1 : 0 }}>
<PdfViewer
data={pdfData}
scale={imageScale}
onScaleChange={onImageScaleChange}
onFirstPageSize={handleFirstPageSize}
/>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Failed to load PDF: {error}
</div>
);
}
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading PDF...
</div>
);
}

View file

@ -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 (
<div
ref={containerRef}
className="min-h-0 flex-1 overflow-auto"
tabIndex={-1}
className="min-h-0 flex-1 overflow-auto outline-none"
style={{ cursor: captureMode ? "crosshair" : undefined }}
onMouseDown={handleCaptureMouseDown}
onMouseMove={handleCaptureMouseMove}

View file

@ -23,7 +23,10 @@ import {
FileSpreadsheetIcon,
GripVerticalIcon,
AppWindowIcon,
FlaskConicalIcon,
TerminalIcon,
} from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import {
DndContext,
DragOverlay,
@ -65,8 +68,9 @@ import {
} from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { useUvSetupStore } from "@/stores/uv-setup-store";
import { UvSetupDialog } from "@/components/uv-setup";
// ─── Table of Contents ───
@ -667,6 +671,9 @@ export function Sidebar() {
</Panel>
</PanelGroup>
{/* Environment section — Python + Skills */}
<EnvironmentSection projectPath={projectRoot} />
{/* Footer */}
<div className="flex items-center justify-between border-sidebar-border border-t px-3 py-2 text-muted-foreground text-xs">
<span className="truncate">ClaudePrism v{APP_VERSION}</span>
@ -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<SkillsStatus | null>(null);
const [showOnboarding, setShowOnboarding] = useState(false);
const checkSkillsStatus = useCallback(async () => {
try {
const globalStatus = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
if (globalStatus.installed) {
setSkillsStatus(globalStatus);
return;
}
if (projectPath) {
const projectStatus = await invoke<SkillsStatus>("check_skills_installed", {
projectPath,
});
setSkillsStatus(projectStatus);
} else {
setSkillsStatus(globalStatus);
}
} catch {
// Ignore errors silently
}
}, [projectPath]);
useEffect(() => {
checkSkillsStatus();
}, [checkSkillsStatus]);
// Lazy import onboarding
const [OnboardingComponent, setOnboardingComponent] = useState<React.ComponentType<{
onClose: () => 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 (
<>
<div className="border-sidebar-border border-t">
<div className="flex h-8 shrink-0 items-center justify-center gap-2 px-3">
<AppWindowIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium text-xs">Environment</span>
</div>
<div className="px-1 pb-1.5 space-y-0.5">
{/* Python / uv row */}
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50 min-w-0"
onClick={() => setShowUvDialog(true)}
>
<TerminalIcon className={cn("size-3.5 shrink-0", venvReady ? "text-foreground" : "text-muted-foreground")} />
<span className="min-w-0 truncate text-xs flex-1">Python</span>
<span className={cn("shrink-0 text-xs", venvReady ? "text-foreground" : "text-muted-foreground")}>
{pythonLabel}
</span>
</button>
{/* Scientific Skills row */}
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50 min-w-0"
onClick={() => setShowOnboarding(true)}
>
<FlaskConicalIcon className={cn("size-3.5 shrink-0", skillsStatus?.installed ? "text-foreground" : "text-muted-foreground")} />
<span className="min-w-0 truncate text-xs flex-1">Skills</span>
<span className={cn("shrink-0 text-xs", skillsStatus?.installed ? "text-foreground" : "text-muted-foreground")}>
{skillsLabel}
</span>
</button>
</div>
</div>
<UvSetupDialog open={showUvDialog} onClose={() => setShowUvDialog(false)} />
{showOnboarding && OnboardingComponent && (
<OnboardingComponent
onClose={() => {
setShowOnboarding(false);
checkSkillsStatus();
}}
/>
)}
</>
);
}
// ─── Draggable wrapper ───
function DraggableItem({ id, type, name, children }: { id: string; type: "file" | "folder"; name: string; children: React.ReactNode }) {

View file

@ -161,7 +161,7 @@ export function ZoteroHeader() {
<span
className={cn(
"size-1.5 rounded-full",
isAuthenticated ? "bg-green-400" : "bg-muted-foreground/30",
isAuthenticated ? "bg-foreground" : "bg-muted-foreground/30",
)}
/>
<span className="font-medium text-xs">Zotero</span>
@ -281,7 +281,7 @@ function CollectionRow({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="truncate text-sm text-foreground">{name}</span>
{isSynced && <CheckIcon className="size-2.5 shrink-0 text-green-500" />}
{isSynced && <CheckIcon className="size-2.5 shrink-0 text-muted-foreground" />}
</div>
{isSynced && (
<p className="truncate text-xs leading-none text-muted-foreground">

View file

@ -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}
`,
},

View file

@ -4,7 +4,6 @@ import {
readTexFileContent,
writeTexFileContent,
readImageAsDataUrl,
getAssetUrl,
createFileOnDisk,
copyFileToProject,
deleteFileFromDisk,
@ -149,10 +148,7 @@ export const useDocumentStore = create<DocumentState>()((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<DocumentState>()((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);
}
}

View file

@ -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<void>;
install: () => Promise<void>;
setupVenv: (projectPath: string) => Promise<void>;
// Internal
_finishInstall: (success: boolean) => void;
}
// ─── Store ───
export const useUvSetupStore = create<UvSetupState>((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<UvStatus>("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<VenvInfo>("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.",
});
}
},
}));