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:
+279
-8
@@ -164,6 +164,88 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
|||||||
Ok(projects)
|
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> {
|
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
let dir = paths.projects_dir().join(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())
|
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)?;
|
check_name(name)?;
|
||||||
let dir = paths.projects_dir().join(name);
|
let dir = paths.projects_dir().join(name);
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Err(format!("Projekt nicht gefunden: {name}"));
|
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())
|
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,13 +701,34 @@ fn list_projects() -> Result<Vec<Project>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn create_project(name: String) -> Result<(), String> {
|
fn create_project_full(
|
||||||
create_project_in(&Paths::real(), &name)
|
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]
|
#[tauri::command]
|
||||||
fn delete_project(name: String) -> Result<(), String> {
|
fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
|
||||||
delete_project_in(&Paths::real(), &name)
|
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]
|
#[tauri::command]
|
||||||
@@ -1067,8 +1204,9 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
list_projects,
|
list_projects,
|
||||||
create_project,
|
create_project_full,
|
||||||
delete_project,
|
delete_project,
|
||||||
|
project_work_dirs,
|
||||||
list_pools,
|
list_pools,
|
||||||
create_oauth_pool,
|
create_oauth_pool,
|
||||||
create_apikey_pool,
|
create_apikey_pool,
|
||||||
@@ -1276,6 +1414,99 @@ mod tests {
|
|||||||
assert!(!p.pool_dir("kunde").exists());
|
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 --
|
// -- Verbrauch --
|
||||||
|
|
||||||
fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String {
|
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() {
|
fn projekt_loeschen() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
create_project_in(&p, "proj").unwrap();
|
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());
|
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]
|
#[test]
|
||||||
fn projekt_loeschen_unbekannt_scheitert() {
|
fn projekt_loeschen_unbekannt_scheitert() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
assert!(delete_project_in(&p, "gibtsnicht").is_err());
|
assert!(delete_project_in(&p, "gibtsnicht", false).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Wizard | null>(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<PendingDelete | null>(null);
|
||||||
|
|
||||||
|
async function askDelete(p: Project) {
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const workDirs = await invoke<string[]>("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;
|
let timer: number;
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -158,6 +253,12 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="primary" @click="openWizard">
|
||||||
|
{{ $t("projects.newProject") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -200,6 +301,14 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
>
|
>
|
||||||
⚙︎
|
⚙︎
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="gear danger"
|
||||||
|
:disabled="p.running"
|
||||||
|
:title="p.running ? $t('projects.deleteRunning') : $t('projects.delete')"
|
||||||
|
@click="askDelete(p)"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
<button v-if="p.running" class="stop" @click="stop(p)">
|
<button v-if="p.running" class="stop" @click="stop(p)">
|
||||||
{{ $t("projects.stop") }}
|
{{ $t("projects.stop") }}
|
||||||
</button>
|
</button>
|
||||||
@@ -218,6 +327,103 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div v-if="wizard" class="overlay" @click.self="wizard = null">
|
||||||
|
<form class="dialog" @submit.prevent="createProject">
|
||||||
|
<h3>{{ $t("projects.wizardTitle") }}</h3>
|
||||||
|
<label class="field">
|
||||||
|
{{ $t("projects.name") }}
|
||||||
|
<input v-model="wizard.name" autofocus required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
{{ $t("projects.pool") }}
|
||||||
|
<select v-model="wizard.pool">
|
||||||
|
<option value="">{{ $t("projects.noPool") }}</option>
|
||||||
|
<option v-for="pool in pools" :key="pool.name" :value="pool.name">
|
||||||
|
{{ pool.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
{{ $t("projects.workDir") }}
|
||||||
|
<span class="icon-row">
|
||||||
|
<select v-model="wizard.workDirMode">
|
||||||
|
<option value="none">{{ $t("projects.workDirNone") }}</option>
|
||||||
|
<option value="default">
|
||||||
|
~/projects/{{ wizard.name.trim() || "…" }}
|
||||||
|
</option>
|
||||||
|
<option value="custom">{{ $t("projects.workDirCustom") }}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
v-if="wizard.workDirMode === 'custom'"
|
||||||
|
type="button"
|
||||||
|
@click="pickWorkDir"
|
||||||
|
>
|
||||||
|
{{ $t("projects.chooseFolder") }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label v-if="wizard.workDirMode === 'custom' && wizard.workDir" class="field">
|
||||||
|
<span></span>
|
||||||
|
<span class="icon-path">{{ wizard.workDir }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
{{ $t("projects.title") }}
|
||||||
|
<input v-model="wizard.title" :placeholder="wizard.name.trim()" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
{{ $t("projects.theme") }}
|
||||||
|
<select v-model="wizard.theme">
|
||||||
|
<option v-for="[value, label] in THEME_NAMES" :key="value" :value="value">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="hint">{{ $t("projects.wizardHint") }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" @click="wizard = null">
|
||||||
|
{{ $t("projects.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary"
|
||||||
|
:disabled="
|
||||||
|
wizard.name.trim() === '' ||
|
||||||
|
(wizard.workDirMode === 'custom' && !wizard.workDir)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ $t("projects.create") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pendingDelete" class="overlay" @click.self="pendingDelete = null">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>{{ $t("projects.deleteTitle", { name: pendingDelete.name }) }}</h3>
|
||||||
|
<p class="hint">{{ $t("projects.deleteWarning", { name: pendingDelete.name }) }}</p>
|
||||||
|
<template v-if="pendingDelete.workDirs.length">
|
||||||
|
<label class="checkline">
|
||||||
|
<input v-model="pendingDelete.deleteWorkDirs" type="checkbox" />
|
||||||
|
{{ $t("projects.deleteWorkDirs") }}
|
||||||
|
</label>
|
||||||
|
<ul class="affected">
|
||||||
|
<li v-for="wd in pendingDelete.workDirs" :key="wd">{{ wd }}</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="!pendingDelete.deleteWorkDirs" class="hint">
|
||||||
|
{{ $t("projects.workDirsStay") }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" @click="pendingDelete = null">
|
||||||
|
{{ $t("projects.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button class="danger" @click="confirmDelete">
|
||||||
|
{{ $t("projects.delete") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="settings" class="overlay">
|
<div v-if="settings" class="overlay">
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<h3>{{ $t("projects.terminal", { name: settings.name }) }}</h3>
|
<h3>{{ $t("projects.terminal", { name: settings.name }) }}</h3>
|
||||||
|
|||||||
+34
@@ -50,6 +50,23 @@ const de = {
|
|||||||
noPoolAssigned: "kein Pool",
|
noPoolAssigned: "kein Pool",
|
||||||
needsPool: "Kein Pool zugeordnet",
|
needsPool: "Kein Pool zugeordnet",
|
||||||
needsKey: "Pool hat keinen API-Key",
|
needsKey: "Pool hat keinen API-Key",
|
||||||
|
newProject: "+ Projekt",
|
||||||
|
wizardTitle: "Neues Projekt",
|
||||||
|
name: "Name",
|
||||||
|
workDir: "Arbeitsordner",
|
||||||
|
workDirNone: "keiner",
|
||||||
|
workDirCustom: "vorhandenen Ordner verknüpfen",
|
||||||
|
chooseFolder: "Ordner wählen …",
|
||||||
|
create: "Anlegen",
|
||||||
|
wizardHint:
|
||||||
|
"Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json mit pool-guard-Hook und die Pool-/Terminal-Zuordnung.",
|
||||||
|
delete: "Projekt löschen",
|
||||||
|
deleteRunning: "Läuft noch — erst beenden",
|
||||||
|
deleteTitle: "{name} löschen",
|
||||||
|
deleteWarning:
|
||||||
|
"Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
|
||||||
|
deleteWorkDirs: "Arbeitsordner mitlöschen:",
|
||||||
|
workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
|
||||||
},
|
},
|
||||||
pools: {
|
pools: {
|
||||||
pool: "Pool",
|
pool: "Pool",
|
||||||
@@ -138,6 +155,23 @@ const en: typeof de = {
|
|||||||
noPoolAssigned: "no pool",
|
noPoolAssigned: "no pool",
|
||||||
needsPool: "No pool assigned",
|
needsPool: "No pool assigned",
|
||||||
needsKey: "Pool has no API key",
|
needsKey: "Pool has no API key",
|
||||||
|
newProject: "+ Project",
|
||||||
|
wizardTitle: "New project",
|
||||||
|
name: "Name",
|
||||||
|
workDir: "Working folder",
|
||||||
|
workDirNone: "none",
|
||||||
|
workDirCustom: "link existing folder",
|
||||||
|
chooseFolder: "Choose folder …",
|
||||||
|
create: "Create",
|
||||||
|
wizardHint:
|
||||||
|
"Creates the project folder with memory/, sentinel .gitignore, settings.json with the pool-guard hook, and the pool/terminal assignment.",
|
||||||
|
delete: "Delete project",
|
||||||
|
deleteRunning: "Still running — stop it first",
|
||||||
|
deleteTitle: "Delete {name}",
|
||||||
|
deleteWarning:
|
||||||
|
"The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.",
|
||||||
|
deleteWorkDirs: "Also delete working folders:",
|
||||||
|
workDirsStay: "The working folders stay untouched.",
|
||||||
},
|
},
|
||||||
pools: {
|
pools: {
|
||||||
pool: "Pool",
|
pool: "Pool",
|
||||||
|
|||||||
@@ -576,6 +576,14 @@ td select {
|
|||||||
color: var(--overlay);
|
color: var(--overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog .checkline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog .affected {
|
.dialog .affected {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.1rem;
|
padding-left: 1.1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user