dev-manager-tauri/src-tauri/src/commands/templates.rs
lanrtop 533c069e43 feat: add WSL editor launch modes and update checking functionality
- 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.
2026-04-01 23:12:23 +09:00

480 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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],
);
}
}
}