- Introduced WSL editor launch modes in SettingsPage with options for internal, remote, and UNC path launches. - Enhanced settings saving to include the new WSL launch mode. - Added an update section to check for application updates, download, and install them with progress tracking. - Updated Toast component to handle error messages with a copy button for convenience. - Expanded commands to include project tags and templates management, along with WSL proxy synchronization features.
480 lines
16 KiB
Rust
480 lines
16 KiB
Rust
use crate::db;
|
||
use rusqlite::params;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::Path;
|
||
use uuid::Uuid;
|
||
|
||
// ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Serialize, Deserialize, Clone)]
|
||
pub struct TemplateFile {
|
||
pub id: String,
|
||
pub template_id: String,
|
||
pub path: String,
|
||
pub content: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ProjectTemplate {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub description: Option<String>,
|
||
pub platform: String, // "win" | "wsl" | "both"
|
||
pub tech_stack: Option<String>,
|
||
pub init_commands: Option<String>,
|
||
pub is_builtin: bool,
|
||
pub created_at: Option<String>,
|
||
#[serde(default)]
|
||
pub files: Vec<TemplateFile>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TemplateFileInput {
|
||
pub path: String,
|
||
pub content: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct SaveTemplateInput {
|
||
pub id: Option<String>,
|
||
pub name: String,
|
||
pub description: Option<String>,
|
||
pub platform: String,
|
||
pub tech_stack: Option<String>,
|
||
pub init_commands: Option<String>,
|
||
pub files: Vec<TemplateFileInput>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CreateProjectResult {
|
||
pub project_id: String,
|
||
pub logs: Vec<String>,
|
||
}
|
||
|
||
// ── Tauri 命令 ────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub fn list_templates() -> Result<Vec<ProjectTemplate>, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at
|
||
FROM project_templates ORDER BY is_builtin DESC, name",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let templates = stmt
|
||
.query_map([], |row| {
|
||
Ok(ProjectTemplate {
|
||
id: row.get(0)?,
|
||
name: row.get(1)?,
|
||
description: row.get(2)?,
|
||
platform: row.get(3)?,
|
||
tech_stack: row.get(4)?,
|
||
init_commands: row.get(5)?,
|
||
is_builtin: row.get::<_, i64>(6)? == 1,
|
||
created_at: row.get(7)?,
|
||
files: vec![],
|
||
})
|
||
})
|
||
.map_err(|e| e.to_string())?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
Ok(templates)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let mut tmpl = conn
|
||
.query_row(
|
||
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at
|
||
FROM project_templates WHERE id = ?1",
|
||
params![id],
|
||
|row| {
|
||
Ok(ProjectTemplate {
|
||
id: row.get(0)?,
|
||
name: row.get(1)?,
|
||
description: row.get(2)?,
|
||
platform: row.get(3)?,
|
||
tech_stack: row.get(4)?,
|
||
init_commands: row.get(5)?,
|
||
is_builtin: row.get::<_, i64>(6)? == 1,
|
||
created_at: row.get(7)?,
|
||
files: vec![],
|
||
})
|
||
},
|
||
)
|
||
.map_err(|e| format!("模板不存在: {e}"))?;
|
||
|
||
let mut file_stmt = conn
|
||
.prepare(
|
||
"SELECT id, template_id, path, content
|
||
FROM project_template_files WHERE template_id = ?1 ORDER BY path",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
tmpl.files = file_stmt
|
||
.query_map(params![id], |row| {
|
||
Ok(TemplateFile {
|
||
id: row.get(0)?,
|
||
template_id: row.get(1)?,
|
||
path: row.get(2)?,
|
||
content: row.get(3)?,
|
||
})
|
||
})
|
||
.map_err(|e| e.to_string())?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
Ok(tmpl)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn save_template(input: SaveTemplateInput) -> Result<String, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||
|
||
conn.execute(
|
||
"INSERT INTO project_templates (id, name, description, platform, tech_stack, init_commands, is_builtin)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
name = excluded.name,
|
||
description = excluded.description,
|
||
platform = excluded.platform,
|
||
tech_stack = excluded.tech_stack,
|
||
init_commands = excluded.init_commands",
|
||
params![id, input.name, input.description, input.platform, input.tech_stack, input.init_commands],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
// 全量替换文件列表
|
||
conn.execute(
|
||
"DELETE FROM project_template_files WHERE template_id = ?1",
|
||
params![id],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
for file in &input.files {
|
||
let file_id = Uuid::new_v4().to_string();
|
||
conn.execute(
|
||
"INSERT INTO project_template_files (id, template_id, path, content) VALUES (?1, ?2, ?3, ?4)",
|
||
params![file_id, id, file.path, file.content],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
|
||
Ok(id)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn delete_template(id: String) -> Result<(), String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute(
|
||
"DELETE FROM project_templates WHERE id = ?1 AND is_builtin = 0",
|
||
params![id],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn create_project_from_template(
|
||
template_id: String,
|
||
parent_path: String,
|
||
project_name: String,
|
||
platform: String,
|
||
space_id: Option<String>,
|
||
) -> Result<CreateProjectResult, String> {
|
||
let tmpl = get_template(template_id)?;
|
||
let mut logs: Vec<String> = Vec::new();
|
||
|
||
// ── 1. 计算项目路径 ───────────────────────────────────────────────────────
|
||
let win_parent = crate::commands::publish::wsl_to_win(&parent_path);
|
||
let project_win_path = format!(
|
||
"{}\\{}",
|
||
win_parent.trim_end_matches('\\'),
|
||
project_name
|
||
);
|
||
let project_root = Path::new(&project_win_path);
|
||
|
||
if project_root.exists() {
|
||
return Err(format!("目录已存在: {}", project_win_path));
|
||
}
|
||
|
||
// ── 2. 创建根目录 ─────────────────────────────────────────────────────────
|
||
std::fs::create_dir_all(project_root)
|
||
.map_err(|e| format!("创建目录失败: {e}"))?;
|
||
logs.push(format!("📁 已创建目录: {}", project_win_path));
|
||
|
||
// ── 3. 写入模板文件 ───────────────────────────────────────────────────────
|
||
for file in &tmpl.files {
|
||
if file.path.trim().is_empty() {
|
||
continue;
|
||
}
|
||
let content = apply_vars(&file.content, &project_name);
|
||
let file_path = project_root.join(&file.path);
|
||
if let Some(parent) = file_path.parent() {
|
||
std::fs::create_dir_all(parent)
|
||
.map_err(|e| format!("创建子目录失败 {}: {e}", file.path))?;
|
||
}
|
||
std::fs::write(&file_path, content)
|
||
.map_err(|e| format!("写入 {} 失败: {e}", file.path))?;
|
||
logs.push(format!("📄 {}", file.path));
|
||
}
|
||
|
||
// ── 4. 执行初始化命令 ─────────────────────────────────────────────────────
|
||
if let Some(cmds) = &tmpl.init_commands {
|
||
let linux_project_path = if platform == "wsl" {
|
||
crate::commands::publish::extract_linux_path(&parent_path, &win_parent)
|
||
.map(|p| format!("{}/{}", p.trim_end_matches('/'), project_name))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
for line in cmds.lines() {
|
||
let cmd = line.trim();
|
||
if cmd.is_empty() || cmd.starts_with('#') {
|
||
continue;
|
||
}
|
||
|
||
logs.push(format!("⚙ 执行: {}", cmd));
|
||
|
||
let output = if platform == "wsl" {
|
||
let lp = linux_project_path
|
||
.as_deref()
|
||
.unwrap_or(&project_win_path);
|
||
std::process::Command::new("wsl")
|
||
.args(["bash", "-c", &format!("cd '{}' && {}", lp, cmd)])
|
||
.output()
|
||
} else {
|
||
std::process::Command::new("cmd")
|
||
.args(["/c", &format!("chcp 65001 > nul && {}", cmd)])
|
||
.current_dir(&project_win_path)
|
||
.output()
|
||
};
|
||
|
||
match output {
|
||
Ok(out) => {
|
||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||
if !stdout.is_empty() {
|
||
logs.push(stdout);
|
||
}
|
||
if !stderr.is_empty() {
|
||
logs.push(format!("⚠ {}", stderr));
|
||
}
|
||
if out.status.success() {
|
||
logs.push(" ✓".to_string());
|
||
} else {
|
||
logs.push(format!(" ✗ 退出码: {}", out.status));
|
||
}
|
||
}
|
||
Err(e) => {
|
||
logs.push(format!(" ✗ 启动失败: {e}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 5. 注册到数据库 ───────────────────────────────────────────────────────
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let profile_id = Uuid::new_v4().to_string();
|
||
let workspace_id = Uuid::new_v4().to_string();
|
||
|
||
conn.execute(
|
||
"INSERT INTO project_profiles (id, name, description, type, status, priority, tech_stack)
|
||
VALUES (?1, ?2, ?3, 'app', 'active', 'mid', ?4)",
|
||
params![profile_id, project_name, tmpl.description, tmpl.tech_stack],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let is_wsl = platform == "wsl";
|
||
let (wsl_path, win_path_col): (Option<&str>, Option<&str>) = if is_wsl {
|
||
(Some(&project_win_path), None)
|
||
} else {
|
||
(None, Some(&project_win_path))
|
||
};
|
||
// 存储时统一用 "windows"(与其他创建路径保持一致)
|
||
let stored_platform = if is_wsl { "wsl" } else { "windows" };
|
||
|
||
conn.execute(
|
||
"INSERT INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||
params![workspace_id, profile_id, space_id, wsl_path, win_path_col, stored_platform],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
logs.push(format!("✅ 项目已注册,ID: {}", workspace_id));
|
||
|
||
Ok(CreateProjectResult {
|
||
project_id: workspace_id,
|
||
logs,
|
||
})
|
||
}
|
||
|
||
// ── 变量替换 ──────────────────────────────────────────────────────────────────
|
||
|
||
fn apply_vars(content: &str, project_name: &str) -> String {
|
||
let kebab = project_name
|
||
.to_lowercase()
|
||
.replace(' ', "-")
|
||
.replace('_', "-");
|
||
let snake = project_name
|
||
.to_lowercase()
|
||
.replace(' ', "_")
|
||
.replace('-', "_");
|
||
content
|
||
.replace("{{project_name}}", project_name)
|
||
.replace("{{project_name_kebab}}", &kebab)
|
||
.replace("{{project_name_snake}}", &snake)
|
||
}
|
||
|
||
// ── 内置模板种子数据 ──────────────────────────────────────────────────────────
|
||
|
||
pub fn seed_builtin_templates() {
|
||
let conn = match db::pool().get() {
|
||
Ok(c) => c,
|
||
Err(_) => return,
|
||
};
|
||
|
||
// 已有内置模板则跳过
|
||
let count: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM project_templates WHERE is_builtin = 1",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.unwrap_or(0);
|
||
if count > 0 {
|
||
return;
|
||
}
|
||
|
||
struct Tpl {
|
||
id: &'static str,
|
||
name: &'static str,
|
||
description: &'static str,
|
||
platform: &'static str,
|
||
tech_stack: &'static str,
|
||
init_commands: &'static str,
|
||
files: Vec<(&'static str, &'static str)>,
|
||
}
|
||
|
||
let templates = vec![
|
||
Tpl {
|
||
id: "builtin-pnpm-monorepo",
|
||
name: "pnpm Monorepo",
|
||
description: "多包 monorepo,含 apps/ 与 packages/ 目录",
|
||
platform: "both",
|
||
tech_stack: "Node,pnpm",
|
||
init_commands: "pnpm install",
|
||
files: vec![
|
||
("package.json", r#"{
|
||
"name": "{{project_name_kebab}}",
|
||
"private": true,
|
||
"scripts": {
|
||
"dev": "pnpm -r --filter './apps/**' dev",
|
||
"build": "pnpm -r --filter './apps/**' build",
|
||
"lint": "pnpm -r lint"
|
||
},
|
||
"engines": {
|
||
"node": ">=18",
|
||
"pnpm": ">=8"
|
||
}
|
||
}
|
||
"#),
|
||
("pnpm-workspace.yaml", r#"packages:
|
||
- 'apps/*'
|
||
- 'packages/*'
|
||
"#),
|
||
(".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"),
|
||
("apps/.gitkeep", ""),
|
||
("packages/.gitkeep", ""),
|
||
(".gitignore", "node_modules/\ndist/\n.next/\n*.log\n.env\n.env.*\n!.env.example\n"),
|
||
],
|
||
},
|
||
Tpl {
|
||
id: "builtin-pnpm-app",
|
||
name: "pnpm App",
|
||
description: "单包 Node.js 应用",
|
||
platform: "both",
|
||
tech_stack: "Node,pnpm",
|
||
init_commands: "pnpm install",
|
||
files: vec![
|
||
("package.json", r#"{
|
||
"name": "{{project_name_kebab}}",
|
||
"version": "0.1.0",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": {
|
||
"start": "node src/index.js",
|
||
"dev": "node --watch src/index.js"
|
||
}
|
||
}
|
||
"#),
|
||
("src/index.js", "console.log('Hello from {{project_name}}!')\n"),
|
||
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n"),
|
||
],
|
||
},
|
||
Tpl {
|
||
id: "builtin-rust",
|
||
name: "Rust",
|
||
description: "Rust 二进制项目",
|
||
platform: "both",
|
||
tech_stack: "Rust",
|
||
init_commands: "cargo build",
|
||
files: vec![
|
||
("Cargo.toml", r#"[package]
|
||
name = "{{project_name_kebab}}"
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
|
||
[dependencies]
|
||
"#),
|
||
("src/main.rs", "fn main() {\n println!(\"Hello from {{project_name}}!\");\n}\n"),
|
||
(".gitignore", "target/\n"),
|
||
],
|
||
},
|
||
Tpl {
|
||
id: "builtin-empty",
|
||
name: "空文件夹",
|
||
description: "只创建目录,不添加任何文件",
|
||
platform: "both",
|
||
tech_stack: "",
|
||
init_commands: "",
|
||
files: vec![],
|
||
},
|
||
];
|
||
|
||
for t in &templates {
|
||
let _ = conn.execute(
|
||
"INSERT OR IGNORE INTO project_templates
|
||
(id, name, description, platform, tech_stack, init_commands, is_builtin)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
|
||
params![
|
||
t.id,
|
||
t.name,
|
||
if t.description.is_empty() { None } else { Some(t.description) },
|
||
t.platform,
|
||
if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) },
|
||
if t.init_commands.is_empty() { None } else { Some(t.init_commands) },
|
||
],
|
||
);
|
||
|
||
for (path, content) in &t.files {
|
||
let file_id = format!("{}-{}", t.id, path.replace('/', "-"));
|
||
let _ = conn.execute(
|
||
"INSERT OR IGNORE INTO project_template_files (id, template_id, path, content)
|
||
VALUES (?1, ?2, ?3, ?4)",
|
||
params![file_id, t.id, path, content],
|
||
);
|
||
}
|
||
}
|
||
}
|