diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33a8c1c..179c3df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,88 @@ fn list_projects_in(paths: &Paths) -> Result, String> { Ok(projects) } +/// "~/x" relativ zum Home auflösen; alles andere unverändert. +fn expand_home(paths: &Paths, p: &str) -> PathBuf { + match p.strip_prefix("~/") { + Some(rest) => paths.home.join(rest), + None => PathBuf::from(p), + } +} + +/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore +/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, +/// pool-guard-Hook), ai-control.json mit Pool/Terminal-Config. Kein git init — +/// der Ordner ist Teil des einen claude-projects-Repos. +fn create_project_full_in( + paths: &Paths, + name: &str, + pool: Option<&str>, + work_dir: Option<&str>, + create_work_dir: bool, + terminal: TerminalConfig, +) -> Result<(), String> { + check_name(name)?; + let dir = paths.projects_dir().join(name); + if dir.exists() { + return Err(format!("Projekt existiert bereits: {name}")); + } + if let Some(pool) = pool { + if !paths.pool_dir(pool).join(POOL_FILE).is_file() { + return Err(format!("Pool existiert nicht: {pool}")); + } + } + // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein + // halbes Projekt. + if let Some(wd) = work_dir { + let wd_path = expand_home(paths, wd); + if create_work_dir { + fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; + } else if !wd_path.is_dir() { + return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display())); + } + } + + fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?; + fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?; + fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?; + + let mut allow = vec![format!("Edit(~/claude-projects/{name}/**)")]; + let mut additional: Vec = Vec::new(); + if let Some(wd) = work_dir { + allow.insert(0, format!("Edit({wd}/**)")); + additional.push(wd.to_string()); + } + let pool_guard = paths + .projects_dir() + .join("robotunits") + .join("bin") + .join("pool-guard"); + let settings = serde_json::json!({ + "autoMemoryDirectory": format!("~/claude-projects/{name}/memory"), + "permissions": { + "allow": allow, + "additionalDirectories": additional, + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": pool_guard.to_string_lossy() } + ] + } + ] + } + }); + let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; + fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?; + + let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal }; + if cfg.pool.is_some() || !cfg.terminal.is_empty() { + write_project_config_in(paths, name, &cfg)?; + } + Ok(()) +} + fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); @@ -173,12 +255,46 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> { fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string()) } -fn delete_project_in(paths: &Paths, name: &str) -> Result<(), String> { +/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. +fn project_work_dirs_in(paths: &Paths, name: &str) -> Result, String> { + check_name(name)?; + let sp = paths + .projects_dir() + .join(name) + .join(".claude") + .join("settings.json"); + if !sp.is_file() { + return Ok(Vec::new()); + } + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + let v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; + Ok( + v["permissions"]["additionalDirectories"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + ) +} + +/// Löscht den Projektordner unter claude-projects; optional auch die +/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen. +fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); if !dir.is_dir() { return Err(format!("Projekt nicht gefunden: {name}")); } + if delete_work_dirs { + for wd in project_work_dirs_in(paths, name)? { + let wd_path = expand_home(paths, &wd); + fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; + } + } fs::remove_dir_all(&dir).map_err(|e| e.to_string()) } @@ -585,13 +701,34 @@ fn list_projects() -> Result, String> { } #[tauri::command] -fn create_project(name: String) -> Result<(), String> { - create_project_in(&Paths::real(), &name) +fn create_project_full( + name: String, + pool: Option, + work_dir: Option, + create_work_dir: bool, + terminal: TerminalConfig, +) -> Result<(), String> { + create_project_full_in( + &Paths::real(), + &name, + pool.as_deref(), + work_dir.as_deref(), + create_work_dir, + terminal, + ) } #[tauri::command] -fn delete_project(name: String) -> Result<(), String> { - delete_project_in(&Paths::real(), &name) +fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> { + if is_running(&name) { + return Err(format!("{name} läuft noch — erst beenden")); + } + delete_project_in(&Paths::real(), &name, delete_work_dirs) +} + +#[tauri::command] +fn project_work_dirs(project: String) -> Result, String> { + project_work_dirs_in(&Paths::real(), &project) } #[tauri::command] @@ -1067,8 +1204,9 @@ fn main_builder() -> tauri::Builder { }) .invoke_handler(tauri::generate_handler![ list_projects, - create_project, + create_project_full, delete_project, + project_work_dirs, list_pools, create_oauth_pool, create_apikey_pool, @@ -1276,6 +1414,99 @@ mod tests { assert!(!p.pool_dir("kunde").exists()); } + // -- Projekt-Wizard -- + + #[test] + fn projekt_wizard_scaffold() { + let p = tmp_paths(); + create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + create_project_full_in( + &p, + "neu", + Some("kunde"), + Some("~/projects/neu"), + true, + TerminalConfig { + theme: Some("dracula".into()), + icon: None, + title: Some("Neu".into()), + }, + ) + .unwrap(); + + let dir = p.projects_dir().join("neu"); + assert!(dir.join("memory").is_dir()); + assert_eq!( + fs::read_to_string(dir.join(".gitignore")).unwrap(), + ".ai-control-running\n" + ); + assert!(p.home.join("projects").join("neu").is_dir()); + + let cfg = read_project_config_in(&p, "neu").unwrap(); + assert_eq!(cfg.pool.as_deref(), Some("kunde")); + assert_eq!(cfg.terminal.title.as_deref(), Some("Neu")); + assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); + + let settings: serde_json::Value = serde_json::from_str( + &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(settings["autoMemoryDirectory"], "~/claude-projects/neu/memory"); + assert_eq!(settings["permissions"]["allow"][0], "Edit(~/projects/neu/**)"); + assert_eq!( + settings["permissions"]["allow"][1], + "Edit(~/claude-projects/neu/**)" + ); + assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu"); + assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("bin/pool-guard")); + } + + #[test] + fn projekt_wizard_minimal_ohne_pool_und_workdir() { + let p = tmp_paths(); + create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default()).unwrap(); + let dir = p.projects_dir().join("neu"); + assert!(dir.join(".claude").join("settings.json").is_file()); + // ohne Pool und Terminal-Config entsteht keine ai-control.json + assert!(!p.project_config("neu").is_file()); + let settings: serde_json::Value = serde_json::from_str( + &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(settings["permissions"]["allow"][0], "Edit(~/claude-projects/neu/**)"); + assert_eq!(settings["permissions"]["additionalDirectories"], serde_json::json!([])); + } + + #[test] + fn projekt_wizard_doppelt_scheitert() { + let p = tmp_paths(); + create_project_in(&p, "neu").unwrap(); + let err = + create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default()) + .unwrap_err(); + assert!(err.contains("existiert bereits")); + } + + #[test] + fn projekt_wizard_fehlendes_workdir_scheitert() { + let p = tmp_paths(); + let err = create_project_full_in( + &p, + "neu", + None, + Some("~/projects/gibtsnicht"), + false, + TerminalConfig::default(), + ) + .unwrap_err(); + assert!(err.contains("Arbeitsverzeichnis fehlt")); + // kein halbes Projekt zurückgeblieben + assert!(!p.projects_dir().join("neu").exists()); + } + // -- Verbrauch -- fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String { @@ -1499,13 +1730,53 @@ mod tests { fn projekt_loeschen() { let p = tmp_paths(); create_project_in(&p, "proj").unwrap(); - delete_project_in(&p, "proj").unwrap(); + delete_project_in(&p, "proj", false).unwrap(); assert!(!p.projects_dir().join("proj").exists()); } + #[test] + fn projekt_loeschen_laesst_arbeitsordner() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + .unwrap(); + delete_project_in(&p, "proj", false).unwrap(); + assert!(!p.projects_dir().join("proj").exists()); + assert!(p.home.join("projects").join("proj").is_dir()); + } + + #[test] + fn projekt_loeschen_mit_arbeitsordner() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + .unwrap(); + delete_project_in(&p, "proj", true).unwrap(); + assert!(!p.projects_dir().join("proj").exists()); + assert!(!p.home.join("projects").join("proj").exists()); + } + + #[test] + fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + .unwrap(); + fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); + assert!(delete_project_in(&p, "proj", true).is_err()); + } + + #[test] + fn projekt_arbeitsordner_auslesen() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + .unwrap(); + assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]); + // Projekt ohne settings.json → leer + create_project_in(&p, "alt").unwrap(); + assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty()); + } + #[test] fn projekt_loeschen_unbekannt_scheitert() { let p = tmp_paths(); - assert!(delete_project_in(&p, "gibtsnicht").is_err()); + assert!(delete_project_in(&p, "gibtsnicht", false).is_err()); } } diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue index 43d8e19..0f14757 100644 --- a/src/components/ProjectList.vue +++ b/src/components/ProjectList.vue @@ -149,6 +149,101 @@ async function saveSettings() { } } +interface Wizard { + name: string; + pool: string; + workDirMode: "none" | "default" | "custom"; + workDir: string | null; + title: string; + theme: string; +} + +const wizard = ref(null); + +function openWizard() { + error.value = ""; + wizard.value = { + name: "", + pool: "", + workDirMode: "none", + workDir: null, + title: "", + theme: "mocha", + }; +} + +async function pickWorkDir() { + const dir = await open({ directory: true, multiple: false }); + if (typeof dir === "string") { + wizard.value!.workDir = dir; + wizard.value!.workDirMode = "custom"; + } +} + +async function createProject() { + const w = wizard.value!; + const name = w.name.trim(); + const workDir = + w.workDirMode === "default" + ? `~/projects/${name}` + : w.workDirMode === "custom" + ? w.workDir + : null; + const title = w.title.trim(); + try { + await invoke("create_project_full", { + name, + pool: w.pool === "" ? null : w.pool, + workDir, + createWorkDir: w.workDirMode === "default", + terminal: { + theme: w.theme === "mocha" ? null : w.theme, + icon: null, + title: title === "" ? null : title, + }, + }); + wizard.value = null; + await refresh(); + } catch (e) { + error.value = String(e); + } +} + +interface PendingDelete { + name: string; + workDirs: string[]; + deleteWorkDirs: boolean; +} + +const pendingDelete = ref(null); + +async function askDelete(p: Project) { + error.value = ""; + try { + const workDirs = await invoke("project_work_dirs", { + project: p.name, + }); + pendingDelete.value = { name: p.name, workDirs, deleteWorkDirs: false }; + } catch (e) { + error.value = String(e); + } +} + +async function confirmDelete() { + const d = pendingDelete.value!; + try { + await invoke("delete_project", { + name: d.name, + deleteWorkDirs: d.deleteWorkDirs, + }); + pendingDelete.value = null; + await refresh(); + } catch (e) { + error.value = String(e); + pendingDelete.value = null; + } +} + let timer: number; onMounted(() => { refresh(); @@ -158,6 +253,12 @@ onUnmounted(() => window.clearInterval(timer));