Projekt-Wizard und Projekt löschen

- create_project_full: Scaffold nach 08-07-Muster (memory/, Sentinel-
  .gitignore, settings.json mit autoMemoryDirectory/Permissions/pool-guard-
  Hook, ai-control.json mit Pool/Terminal); Arbeitsordner anlegen oder
  vorhandenen verknuepfen, Pruefung vor dem Anlegen; ersetzt create_project
- Wizard-Dialog: Name, Pool, Arbeitsordner (keiner/anlegen/Picker),
  Terminal-Titel + Theme
- delete_project: Guard bei laufender Session, optional Arbeitsordner
  mitloeschen (additionalDirectories, via project_work_dirs); Papierkorb-
  Button mit Bestaetigungs-Dialog inkl. Checkbox und Pfadliste
- 8 neue Tests (36 gesamt), i18n de/en
This commit is contained in:
marcus.hinz
2026-07-04 14:26:10 +02:00
parent f8fcd8d033
commit 42d975874e
4 changed files with 527 additions and 8 deletions
+279 -8
View File
@@ -164,6 +164,88 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, 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<String> = 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<Vec<String>, 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<Vec<Project>, String> {
}
#[tauri::command]
fn create_project(name: String) -> Result<(), String> {
create_project_in(&Paths::real(), &name)
fn create_project_full(
name: String,
pool: Option<String>,
work_dir: Option<String>,
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<Vec<String>, String> {
project_work_dirs_in(&Paths::real(), &project)
}
#[tauri::command]
@@ -1067,8 +1204,9 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
})
.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());
}
}