mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
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:
parent
98a7b4f9f5
commit
30b275e643
25 changed files with 3051 additions and 131 deletions
22
apps/desktop/src-tauri/Cargo.lock
generated
22
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
761
apps/desktop/src-tauri/src/skills.rs
Normal file
761
apps/desktop/src-tauri/src/skills.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
350
apps/desktop/src-tauri/src/uv.rs
Normal file
350
apps/desktop/src-tauri/src/uv.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
40
apps/desktop/src/components/ui/badge.tsx
Normal file
40
apps/desktop/src/components/ui/badge.tsx
Normal 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 };
|
||||
51
apps/desktop/src/components/ui/hover-card.tsx
Normal file
51
apps/desktop/src/components/ui/hover-card.tsx
Normal 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 };
|
||||
29
apps/desktop/src/components/ui/progress.tsx
Normal file
29
apps/desktop/src/components/ui/progress.tsx
Normal 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 };
|
||||
50
apps/desktop/src/components/ui/scroll-area.tsx
Normal file
50
apps/desktop/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||
67
apps/desktop/src/components/ui/tabs.tsx
Normal file
67
apps/desktop/src/components/ui/tabs.tsx
Normal 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 };
|
||||
206
apps/desktop/src/components/uv-setup.tsx
Normal file
206
apps/desktop/src/components/uv-setup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
± {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">
|
||||
± {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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
124
apps/desktop/src/stores/uv-setup-store.ts
Normal file
124
apps/desktop/src/stores/uv-setup-store.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
Loading…
Add table
Reference in a new issue