Projekt-Registry statt festem ~/claude-projects-Root: projects.json (Name → Pfad, ~-relativ) mit Alt-Layout-Fallback; Ordner frei mappbar (add/remove_project, Wizard-Ablageort); Session-Sync per Projekt-Repo (add/commit/pull --rebase/push) statt git-sync-Skript; README + GitHub-Release-Fassung

This commit is contained in:
marcus.hinz
2026-07-05 13:58:52 +02:00
parent ddd8984462
commit ec3d2f4fb3
5 changed files with 419 additions and 111 deletions
+71 -3
View File
@@ -1,5 +1,73 @@
# Vue 3 + TypeScript + Vite # ai-control
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup). > Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.
## Zweck
Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:
- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und synct dann (opt-in) das Git-Repo, in dem das Projekt liegt: add → commit → pull --rebase → push.
## Projektaufbau
```
├── index.html Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html Terminal-Fenster (xterm.js)
├── src/ Frontend: Vue 3 + TypeScript
├── src-tauri/
│ ├── src/lib.rs Tauri-Commands: Projekte, Pools, Keychain,
│ │ OAuth-Login, Tray-Menü, Session-Watcher
│ ├── src/terminal.rs PTY-Sessions (portable-pty), Terminal-Fenster,
│ │ Dock-Icon, Fokussieren laufender Terminals
│ └── icons/ App- und Tray-Icons
├── dev.sh Entwicklungsmodus (tauri dev)
└── build.sh Release-Build (.app-Bundle)
```
Zwei Prozessrollen aus einem Binary:
- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.
Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.
## Tooling
| Bereich | Stack |
|-----------|-------|
| Shell | Tauri 2 (Rust) |
| Frontend | Vue 3, TypeScript, Vite |
| Terminal | xterm.js, portable-pty |
| macOS | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets | keyring (macOS Keychain) |
## Entwicklung
```sh
./dev.sh # tauri dev mit Hot-Reload
./build.sh # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```
`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.
Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.
## Konfigurationslayout
```
~/.config/ai-control/
├── settings.json App-Einstellungen (z. B. syncOnSessionEnd)
├── projects.json Projekt-Registry: Name → Ordner
└── pools/<pool>/ je Pool ein Claude-Config-Verzeichnis
└── pool.json Name + Credential-Typ
<projektordner>/ beliebiger Pfad, per Registry gemappt
├── .claude/ Claude-Code-Projektkonfiguration
└── ai-control.json Pool-Zuordnung + Terminal-Einstellungen
```
+258 -98
View File
@@ -16,19 +16,22 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const APIKEY_FILE: &str = "apikey"; const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json"; const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json"; const PROJECT_FILE: &str = "ai-control.json";
const PROJECTS_FILE: &str = "projects.json";
/// Wurzelpfade; in Tests mit temporärem home instanziierbar. /// Wurzelpfade; in Tests mit temporärem home instanziierbar.
struct Paths { pub(crate) struct Paths {
home: PathBuf, home: PathBuf,
} }
impl Paths { impl Paths {
fn real() -> Self { pub(crate) fn real() -> Self {
Paths { Paths {
home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")), home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
} }
} }
/// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
/// Projekte ohne gewählten Zielordner.
fn projects_dir(&self) -> PathBuf { fn projects_dir(&self) -> PathBuf {
self.home.join("claude-projects") self.home.join("claude-projects")
} }
@@ -37,6 +40,10 @@ impl Paths {
self.home.join(".config").join("ai-control") self.home.join(".config").join("ai-control")
} }
fn projects_file(&self) -> PathBuf {
self.config_dir().join(PROJECTS_FILE)
}
fn pools_dir(&self) -> PathBuf { fn pools_dir(&self) -> PathBuf {
self.config_dir().join("pools") self.config_dir().join("pools")
} }
@@ -44,12 +51,93 @@ impl Paths {
fn pool_dir(&self, pool: &str) -> PathBuf { fn pool_dir(&self, pool: &str) -> PathBuf {
self.pools_dir().join(pool) self.pools_dir().join(pool)
} }
}
fn project_config(&self, project: &str) -> PathBuf { // ---------- Projekt-Registry ----------
self.projects_dir().join(project).join(PROJECT_FILE)
/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
match p.strip_prefix(&paths.home) {
Ok(rest) => format!("~/{}", rest.display()),
Err(_) => p.display().to_string(),
} }
} }
/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout:
/// Unterordner von ~/claude-projects mit .claude-Ordner.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
let file = paths.projects_file();
if file.is_file() {
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let map: std::collections::BTreeMap<String, String> =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
return Ok(
map
.into_iter()
.map(|(name, p)| {
let dir = expand_home(paths, &p);
(name, dir)
})
.collect(),
);
}
let mut map = std::collections::BTreeMap::new();
let Ok(entries) = fs::read_dir(paths.projects_dir()) else {
return Ok(map); // kein Alt-Layout → keine Projekte
};
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
if entry.path().join(".claude").is_dir() {
map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path());
}
}
Ok(map)
}
fn save_registry(
paths: &Paths,
reg: &std::collections::BTreeMap<String, PathBuf>,
) -> Result<(), String> {
let contracted: std::collections::BTreeMap<&String, String> =
reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
let file = paths.projects_file();
fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
}
/// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
load_registry(paths)?
.remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))
}
/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die
/// per Alt-Layout gefundenen Einträge in projects.json.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
let mut reg = load_registry(paths)?;
if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}"));
}
reg.insert(name.to_string(), dir.to_path_buf());
save_registry(paths, &reg)
}
/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
let mut reg = load_registry(paths)?;
if reg.remove(name).is_none() {
return Err(format!("Projekt nicht registriert: {name}"));
}
save_registry(paths, &reg)
}
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
}
#[derive(Serialize)] #[derive(Serialize)]
struct Project { struct Project {
name: String, name: String,
@@ -145,8 +233,12 @@ fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
} }
fn is_git_repo(dir: &PathBuf) -> bool { /// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
dir.join(".git").exists() fn git_root(dir: &std::path::Path) -> Option<PathBuf> {
dir
.ancestors()
.find(|a| a.join(".git").exists())
.map(|a| a.to_path_buf())
} }
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die /// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
@@ -159,22 +251,45 @@ fn project_state(paths: &Paths) -> Vec<(String, bool)> {
state state
} }
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das /// Committet/pusht das Repo des Projekts nach einem Session-Ende:
/// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher). /// add -A → commitpull --rebasepush. Nichts zu committen beendet still;
fn sync_session_context(home: &PathBuf, project: &str) { /// jeder andere Fehler bricht die Kette mit Meldung ab.
let git_sync = home.join("claude-projects/pool/bin/git-sync"); fn sync_session_context(repo: &std::path::Path, project: &str) {
let repo = home.join("claude-projects"); let run = |args: &[&str]| {
match Command::new(&git_sync) Command::new("git")
.arg(&repo) .arg("-C")
.arg(format!("session-end sync: {project}")) .arg(repo)
.args(args)
.output() .output()
{ .map_err(|e| format!("git {}: {e}", args.join(" ")))
};
let msg = format!("session-end sync: {project}");
let steps: [&[&str]; 4] = [
&["add", "-A"],
&["commit", "-m", &msg],
&["pull", "--rebase"],
&["push"],
];
for (i, args) in steps.iter().enumerate() {
match run(args) {
Ok(o) if o.status.success() => {} Ok(o) if o.status.success() => {}
Ok(o) => eprintln!( // commit ohne Änderungen: nichts zu syncen, kein Fehler.
"session-end sync {project}: {}", Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => {
return;
}
Ok(o) => {
eprintln!(
"session-end sync {project}: git {}{}",
args.join(" "),
String::from_utf8_lossy(&o.stderr).trim() String::from_utf8_lossy(&o.stderr).trim()
), );
Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"), return;
}
Err(e) => {
eprintln!("session-end sync {project}: {e}");
return;
}
}
} }
} }
@@ -200,14 +315,15 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
}) })
.map(|(name, _)| name) .map(|(name, _)| name)
.collect(); .collect();
// Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray // Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
// verlangt kein Repo, er nutzt es nur, falls vorhanden. // der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
if !ended.is_empty() if !ended.is_empty() && sync_on_session_end(&paths) {
&& sync_on_session_end(&paths)
&& is_git_repo(&paths.projects_dir())
{
for project in ended { for project in ended {
sync_session_context(&paths.home, project); if let Ok(dir) = project_dir(&paths, project) {
if let Some(repo) = git_root(&dir) {
sync_session_context(&repo, project);
}
}
} }
} }
// Menü-Objekte gehören auf den Main-Thread. // Menü-Objekte gehören auf den Main-Thread.
@@ -245,24 +361,16 @@ fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> { fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
let mut projects = Vec::new(); let mut projects = Vec::new();
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?; for (name, dir) in load_registry(paths)? {
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if !path.join(".claude").is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let cfg = read_project_config_in(paths, &name)?; let cfg = read_project_config_in(paths, &name)?;
projects.push(Project { projects.push(Project {
running: is_running(&name), running: is_running(&name),
path: path.to_string_lossy().into_owned(), path: dir.to_string_lossy().into_owned(),
pool: cfg.pool, pool: cfg.pool,
terminal: cfg.terminal, terminal: cfg.terminal,
name, name,
}); });
} }
projects.sort_by(|a, b| a.name.cmp(&b.name));
Ok(projects) Ok(projects)
} }
@@ -273,19 +381,15 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
const TODO_FILE: &str = "OFFENE-PUNKTE.md"; const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n"; const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
fn todo_hook_command(paths: &Paths, name: &str) -> String { fn todo_hook_command(dir: &std::path::Path) -> String {
format!( format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}", "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
paths.projects_dir().join(name).join(TODO_FILE).display() dir.join(TODO_FILE).display()
) )
} }
fn settings_path(paths: &Paths, name: &str) -> PathBuf { fn settings_path(dir: &std::path::Path) -> PathBuf {
paths dir.join(".claude").join("settings.json")
.projects_dir()
.join(name)
.join(".claude")
.join("settings.json")
} }
fn hook_is_todo(group: &serde_json::Value) -> bool { fn hook_is_todo(group: &serde_json::Value) -> bool {
@@ -303,7 +407,7 @@ fn hook_is_todo(group: &serde_json::Value) -> bool {
fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> { fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
check_name(name)?; check_name(name)?;
let sp = settings_path(paths, name); let sp = settings_path(&project_dir(paths, name)?);
if !sp.is_file() { if !sp.is_file() {
return Ok(false); return Ok(false);
} }
@@ -320,7 +424,8 @@ fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> { fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
check_name(name)?; check_name(name)?;
let sp = settings_path(paths, name); let dir = project_dir(paths, name)?;
let sp = settings_path(&dir);
if !sp.is_file() { if !sp.is_file() {
return Err(format!("settings.json fehlt: {}", sp.display())); return Err(format!("settings.json fehlt: {}", sp.display()));
} }
@@ -346,11 +451,11 @@ fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
0, 0,
serde_json::json!({ serde_json::json!({
"hooks": [ "hooks": [
{ "type": "command", "command": todo_hook_command(paths, name) } { "type": "command", "command": todo_hook_command(&dir) }
] ]
}), }),
); );
let todo_path = paths.projects_dir().join(name).join(TODO_FILE); let todo_path = dir.join(TODO_FILE);
if !todo_path.is_file() { if !todo_path.is_file() {
fs::write(&todo_path, TODO_SKELETON) fs::write(&todo_path, TODO_SKELETON)
.map_err(|e| format!("{}: {e}", todo_path.display()))?; .map_err(|e| format!("{}: {e}", todo_path.display()))?;
@@ -371,11 +476,12 @@ fn expand_home(paths: &Paths, p: &str) -> PathBuf {
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore /// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, /// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init — /// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
/// der Ordner ist Teil des einen claude-projects-Repos. /// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
fn create_project_full_in( fn create_project_full_in(
paths: &Paths, paths: &Paths,
name: &str, name: &str,
dir: Option<&str>,
pool: Option<&str>, pool: Option<&str>,
work_dir: Option<&str>, work_dir: Option<&str>,
create_work_dir: bool, create_work_dir: bool,
@@ -383,10 +489,19 @@ fn create_project_full_in(
todo: bool, todo: bool,
) -> Result<(), String> { ) -> Result<(), String> {
check_name(name)?; check_name(name)?;
let dir = paths.projects_dir().join(name); let dir = match dir {
if dir.exists() { Some(d) => expand_home(paths, d),
None => paths.projects_dir().join(name),
};
// Registry-Snapshot vor dem Anlegen — der Alt-Layout-Scan würde den frisch
// angelegten Ordner sonst schon als bestehendes Projekt sehen.
let mut reg = load_registry(paths)?;
if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}")); return Err(format!("Projekt existiert bereits: {name}"));
} }
if dir.exists() {
return Err(format!("Ordner existiert bereits: {}", dir.display()));
}
if let Some(pool) = pool { if let Some(pool) = pool {
if !paths.pool_dir(pool).join(POOL_FILE).is_file() { if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
return Err(format!("Pool existiert nicht: {pool}")); return Err(format!("Pool existiert nicht: {pool}"));
@@ -407,14 +522,15 @@ fn create_project_full_in(
fs::create_dir_all(dir.join("memory")).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())?; 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 contracted = contract_home(paths, &dir);
let mut allow = vec![format!("Edit({contracted}/**)")];
let mut additional: Vec<String> = Vec::new(); let mut additional: Vec<String> = Vec::new();
if let Some(wd) = work_dir { if let Some(wd) = work_dir {
allow.insert(0, format!("Edit({wd}/**)")); allow.insert(0, format!("Edit({wd}/**)"));
additional.push(wd.to_string()); additional.push(wd.to_string());
} }
let settings = serde_json::json!({ let settings = serde_json::json!({
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"), "autoMemoryDirectory": format!("{contracted}/memory"),
"permissions": { "permissions": {
"allow": allow, "allow": allow,
"additionalDirectories": additional, "additionalDirectories": additional,
@@ -423,6 +539,8 @@ fn create_project_full_in(
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; 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())?; fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
reg.insert(name.to_string(), dir.clone());
save_registry(paths, &reg)?;
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal }; let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
if cfg.pool.is_some() || !cfg.terminal.is_empty() { if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?; write_project_config_in(paths, name, &cfg)?;
@@ -433,6 +551,21 @@ fn create_project_full_in(
Ok(()) Ok(())
} }
/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
let dir = expand_home(paths, path);
if !dir.is_dir() {
return Err(format!("Ordner nicht gefunden: {}", dir.display()));
}
let name = dir
.file_name()
.ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
.to_string_lossy()
.into_owned();
check_name(&name)?;
register_project(paths, &name, &dir)
}
#[cfg(test)] #[cfg(test)]
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)?;
@@ -440,17 +573,19 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
if dir.exists() { if dir.exists() {
return Err(format!("Projekt existiert bereits: {name}")); return Err(format!("Projekt existiert bereits: {name}"));
} }
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())?;
// Registrieren nur, wenn die Registry schon materialisiert ist — sonst
// greift der Alt-Layout-Scan.
if paths.projects_file().is_file() {
register_project(paths, name, &dir)?;
}
Ok(())
} }
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. /// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> { fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
check_name(name)?; check_name(name)?;
let sp = paths let sp = settings_path(&project_dir(paths, name)?);
.projects_dir()
.join(name)
.join(".claude")
.join("settings.json");
if !sp.is_file() { if !sp.is_file() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -469,11 +604,11 @@ fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String
) )
} }
/// Löscht den Projektordner unter claude-projects; optional auch die /// Löscht den Projektordner und den Registry-Eintrag; optional auch die
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen. /// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> { 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 = project_dir(paths, name)?;
if !dir.is_dir() { if !dir.is_dir() {
return Err(format!("Projekt nicht gefunden: {name}")); return Err(format!("Projekt nicht gefunden: {name}"));
} }
@@ -483,11 +618,16 @@ fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Resul
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; 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())?;
// Vor der Materialisierung gab es keinen Eintrag zu entfernen.
if paths.projects_file().is_file() {
unregister_project(paths, name)?;
}
Ok(())
} }
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> { fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
let cfg_path = paths.project_config(project); let cfg_path = project_config_path(paths, project)?;
if !cfg_path.is_file() { if !cfg_path.is_file() {
return Ok(ProjectConfig::default()); return Ok(ProjectConfig::default());
} }
@@ -502,7 +642,7 @@ fn write_project_config_in(
cfg: &ProjectConfig, cfg: &ProjectConfig,
) -> Result<(), String> { ) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?; let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string()) fs::write(project_config_path(paths, project)?, raw + "\n").map_err(|e| e.to_string())
} }
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> { fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
@@ -520,7 +660,7 @@ fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?; let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = None; cfg.pool = None;
if cfg.terminal.is_empty() { if cfg.terminal.is_empty() {
let cfg_path = paths.project_config(project); let cfg_path = project_config_path(paths, project)?;
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())); return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
} }
write_project_config_in(paths, project, &cfg) write_project_config_in(paths, project, &cfg)
@@ -542,7 +682,7 @@ fn set_terminal_config_in(
.unwrap_or("png") .unwrap_or("png")
.to_lowercase(); .to_lowercase();
let name = format!("icon.{ext}"); let name = format!("icon.{ext}");
let dest = paths.projects_dir().join(project).join(&name); let dest = project_dir(paths, project)?.join(&name);
if src != dest { if src != dest {
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
} }
@@ -555,11 +695,11 @@ fn set_terminal_config_in(
} }
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner. /// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> PathBuf { fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> Result<PathBuf, String> {
if icon.starts_with('/') { if icon.starts_with('/') {
PathBuf::from(icon) Ok(PathBuf::from(icon))
} else { } else {
paths.projects_dir().join(project).join(icon) Ok(project_dir(paths, project)?.join(icon))
} }
} }
@@ -572,7 +712,7 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else { let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
return Ok(None); return Ok(None);
}; };
let path = resolve_icon_path(&paths, &project, &icon); let path = resolve_icon_path(&paths, &project, &icon)?;
let is_icns = path let is_icns = path
.extension() .extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
@@ -625,7 +765,7 @@ fn menu_icon(
} }
} }
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon { if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
let src = resolve_icon_path(paths, project, &icon); let src = resolve_icon_path(paths, project, &icon)?;
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png")); let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
let out = Command::new("sips") let out = Command::new("sips")
.args(["-s", "format", "png", "-z", "36", "36"]) .args(["-s", "format", "png", "-z", "36", "36"])
@@ -661,20 +801,17 @@ pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
/// Namen aller Projekte, die `pool` zugeordnet haben. /// Namen aller Projekte, die `pool` zugeordnet haben.
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> { fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
let mut users = Vec::new(); let mut users = Vec::new();
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?; for (name, dir) in load_registry(paths)? {
for entry in entries { let cfg_path = dir.join(PROJECT_FILE);
let entry = entry.map_err(|e| e.to_string())?;
let cfg_path = entry.path().join(PROJECT_FILE);
if !cfg_path.is_file() { if !cfg_path.is_file() {
continue; continue;
} }
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?; let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?; let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
if cfg.pool.as_deref() == Some(pool) { if cfg.pool.as_deref() == Some(pool) {
users.push(entry.file_name().to_string_lossy().into_owned()); users.push(name);
} }
} }
users.sort();
Ok(users) Ok(users)
} }
@@ -1219,13 +1356,8 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
// kodierter Projektpfad → Projektname // kodierter Projektpfad → Projektname
let mut names = HashMap::new(); let mut names = HashMap::new();
for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? { for (name, dir) in load_registry(paths)? {
let entry = entry.map_err(|e| e.to_string())?; names.insert(encode_project_path(&dir.to_string_lossy()), name);
if !entry.path().join(".claude").is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
names.insert(encode_project_path(&entry.path().to_string_lossy()), name);
} }
let mut seen = HashSet::new(); let mut seen = HashSet::new();
@@ -1291,6 +1423,7 @@ fn list_projects() -> Result<Vec<Project>, String> {
#[tauri::command] #[tauri::command]
fn create_project_full( fn create_project_full(
name: String, name: String,
dir: Option<String>,
pool: Option<String>, pool: Option<String>,
work_dir: Option<String>, work_dir: Option<String>,
create_work_dir: bool, create_work_dir: bool,
@@ -1300,6 +1433,7 @@ fn create_project_full(
create_project_full_in( create_project_full_in(
&Paths::real(), &Paths::real(),
&name, &name,
dir.as_deref(),
pool.as_deref(), pool.as_deref(),
work_dir.as_deref(), work_dir.as_deref(),
create_work_dir, create_work_dir,
@@ -1308,6 +1442,27 @@ fn create_project_full(
) )
} }
/// Bestehenden Ordner als Projekt aufnehmen (nur Registry-Eintrag).
#[tauri::command]
fn add_project(path: String) -> Result<(), String> {
add_project_in(&Paths::real(), &path)
}
/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
fn remove_project(name: String) -> Result<(), String> {
if is_running(&name) {
return Err(format!("{name} läuft noch — erst beenden"));
}
let paths = Paths::real();
// Alt-Layout erst materialisieren, sonst taucht das Projekt beim
// nächsten Scan wieder auf.
if !paths.projects_file().is_file() {
save_registry(&paths, &load_registry(&paths)?)?;
}
unregister_project(&paths, &name)
}
#[tauri::command] #[tauri::command]
fn todo_state(project: String) -> Result<bool, String> { fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project) todo_state_in(&Paths::real(), &project)
@@ -1520,6 +1675,7 @@ pub fn run() {
.icon .icon
.map(|i| { .map(|i| {
resolve_icon_path(&Paths::real(), &project, &i) resolve_icon_path(&Paths::real(), &project, &i)
.expect("Projekt nicht registriert")
.to_string_lossy() .to_string_lossy()
.into_owned() .into_owned()
}); });
@@ -1655,6 +1811,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
list_projects, list_projects,
create_project_full, create_project_full,
add_project,
remove_project,
delete_project, delete_project,
project_work_dirs, project_work_dirs,
list_pools, list_pools,
@@ -2040,6 +2198,7 @@ mod tests {
create_project_full_in( create_project_full_in(
&p, &p,
"neu", "neu",
None,
Some(&kunde), Some(&kunde),
Some("~/projects/neu"), Some("~/projects/neu"),
true, true,
@@ -2089,7 +2248,7 @@ mod tests {
#[test] #[test]
fn todo_zuschalten_und_abschalten() { fn todo_zuschalten_und_abschalten() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, false, TerminalConfig::default(), false) create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap(); .unwrap();
assert!(!todo_state_in(&p, "proj").unwrap()); assert!(!todo_state_in(&p, "proj").unwrap());
@@ -2101,7 +2260,7 @@ mod tests {
// doppelt aktivieren erzeugt keinen zweiten Hook // doppelt aktivieren erzeugt keinen zweiten Hook
set_todo_in(&p, "proj", true).unwrap(); set_todo_in(&p, "proj", true).unwrap();
let settings: serde_json::Value = serde_json::from_str( let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(settings_path(&p, "proj")).unwrap(), &fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
) )
.unwrap(); .unwrap();
let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
@@ -2126,11 +2285,11 @@ mod tests {
#[test] #[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() { fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false).unwrap(); create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap();
let dir = p.projects_dir().join("neu"); let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file()); assert!(dir.join(".claude").join("settings.json").is_file());
// ohne Pool und Terminal-Config entsteht keine ai-control.json // ohne Pool und Terminal-Config entsteht keine ai-control.json
assert!(!p.project_config("neu").is_file()); assert!(!project_config_path(&p, "neu").unwrap().is_file());
let settings: serde_json::Value = serde_json::from_str( let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
) )
@@ -2144,7 +2303,7 @@ mod tests {
let p = tmp_paths(); let p = tmp_paths();
create_project_in(&p, "neu").unwrap(); create_project_in(&p, "neu").unwrap();
let err = let err =
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false) create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
.unwrap_err(); .unwrap_err();
assert!(err.contains("existiert bereits")); assert!(err.contains("existiert bereits"));
} }
@@ -2156,6 +2315,7 @@ mod tests {
&p, &p,
"neu", "neu",
None, None,
None,
Some("~/projects/gibtsnicht"), Some("~/projects/gibtsnicht"),
false, false,
TerminalConfig::default(), TerminalConfig::default(),
@@ -2295,7 +2455,7 @@ mod tests {
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
delete_pool_in(&p, &store, &kunde).unwrap(); delete_pool_in(&p, &store, &kunde).unwrap();
assert!(!p.pool_dir(&kunde).exists()); assert!(!p.pool_dir(&kunde).exists());
assert!(!p.project_config("proj").exists()); assert!(!project_config_path(&p, "proj").unwrap().exists());
} }
#[test] #[test]
@@ -2360,7 +2520,7 @@ mod tests {
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
let cfg: ProjectConfig = let cfg: ProjectConfig =
serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap()) serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap())
.unwrap(); .unwrap();
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
@@ -2382,7 +2542,7 @@ mod tests {
create_project_in(&p, "proj").unwrap(); create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
unassign_pool_in(&p, "proj").unwrap(); unassign_pool_in(&p, "proj").unwrap();
assert!(!p.project_config("proj").exists()); assert!(!project_config_path(&p, "proj").unwrap().exists());
} }
#[test] #[test]
@@ -2404,7 +2564,7 @@ mod tests {
assign_pool_in(&p, "proj", &privat).unwrap(); assign_pool_in(&p, "proj", &privat).unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
let raw = fs::read_to_string(p.project_config("proj")).unwrap(); let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap();
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap(); let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(cfg, serde_json::json!({ "pool": kunde })); assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
} }
@@ -2441,7 +2601,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_laesst_arbeitsordner() { fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap(); .unwrap();
delete_project_in(&p, "proj", false).unwrap(); delete_project_in(&p, "proj", false).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
@@ -2451,7 +2611,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_mit_arbeitsordner() { fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap(); .unwrap();
delete_project_in(&p, "proj", true).unwrap(); delete_project_in(&p, "proj", true).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
@@ -2461,7 +2621,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap(); .unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_in(&p, "proj", true).is_err()); assert!(delete_project_in(&p, "proj", true).is_err());
@@ -2470,7 +2630,7 @@ mod tests {
#[test] #[test]
fn projekt_arbeitsordner_auslesen() { fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap(); .unwrap();
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]); assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer // Projekt ohne settings.json → leer
+2 -5
View File
@@ -2,7 +2,6 @@ use base64::{engine::general_purpose::STANDARD, Engine};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
@@ -21,8 +20,7 @@ pub struct Terminals(pub Mutex<HashMap<String, Session>>);
/// eigenes Dock-Icon bekommt. /// eigenes Dock-Icon bekommt.
#[tauri::command] #[tauri::command]
pub fn open_terminal(project: String) -> Result<(), String> { pub fn open_terminal(project: String) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?; let dir = crate::project_dir(&crate::Paths::real(), &project)?;
let dir = PathBuf::from(home).join("claude-projects").join(&project);
if !dir.is_dir() { if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display())); return Err(format!("Projektordner fehlt: {}", dir.display()));
} }
@@ -123,8 +121,7 @@ pub fn term_start(
rows: u16, rows: u16,
cols: u16, cols: u16,
) -> Result<(), String> { ) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?; let cwd = crate::project_dir(&crate::Paths::real(), &project)?;
let cwd = PathBuf::from(home).join("claude-projects").join(&project);
let pty = native_pty_system() let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
+69
View File
@@ -205,6 +205,8 @@ async function saveSettings() {
interface Wizard { interface Wizard {
name: string; name: string;
pool: string; pool: string;
dirMode: "default" | "custom";
dir: string | null;
workDirMode: "none" | "default" | "custom"; workDirMode: "none" | "default" | "custom";
workDir: string | null; workDir: string | null;
title: string; title: string;
@@ -219,6 +221,8 @@ function openWizard() {
wizard.value = { wizard.value = {
name: "", name: "",
pool: "", pool: "",
dirMode: "default",
dir: null,
workDirMode: "none", workDirMode: "none",
workDir: null, workDir: null,
title: "", title: "",
@@ -227,6 +231,26 @@ function openWizard() {
}; };
} }
// Bestehenden Ordner als Projekt aufnehmen — Name ist der Ordnername.
async function addFolder() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("add_project", { path: dir });
await refresh();
} catch (e) {
error.value = String(e);
}
}
async function pickProjectDir() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir === "string") {
wizard.value!.dir = dir;
wizard.value!.dirMode = "custom";
}
}
async function pickWorkDir() { async function pickWorkDir() {
const dir = await open({ directory: true, multiple: false }); const dir = await open({ directory: true, multiple: false });
if (typeof dir === "string") { if (typeof dir === "string") {
@@ -248,6 +272,8 @@ async function createProject() {
try { try {
await invoke("create_project_full", { await invoke("create_project_full", {
name, name,
// custom: gewählter Ordner ist der Ablageort, das Projekt entsteht darin
dir: w.dirMode === "custom" ? `${w.dir}/${name}` : null,
pool: w.pool === "" ? null : w.pool, pool: w.pool === "" ? null : w.pool,
workDir, workDir,
createWorkDir: w.workDirMode === "default", createWorkDir: w.workDirMode === "default",
@@ -300,6 +326,19 @@ async function confirmDelete() {
} }
} }
// Nur den Registry-Eintrag entfernen; der Ordner bleibt.
async function unlinkProject() {
const d = pendingDelete.value!;
try {
await invoke("remove_project", { name: d.name });
pendingDelete.value = null;
await refresh();
} catch (e) {
error.value = String(e);
pendingDelete.value = null;
}
}
let timer: number; let timer: number;
onMounted(() => { onMounted(() => {
refresh(); refresh();
@@ -313,6 +352,9 @@ onUnmounted(() => window.clearInterval(timer));
<button class="primary" @click="openWizard"> <button class="primary" @click="openWizard">
{{ $t("projects.newProject") }} {{ $t("projects.newProject") }}
</button> </button>
<button :title="$t('projects.addFolderTitle')" @click="addFolder">
{{ $t("projects.addFolder") }}
</button>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -393,6 +435,28 @@ onUnmounted(() => window.clearInterval(timer));
{{ $t("projects.name") }} {{ $t("projects.name") }}
<input v-model="wizard.name" autofocus required /> <input v-model="wizard.name" autofocus required />
</label> </label>
<label class="field">
{{ $t("projects.projectDir") }}
<span class="icon-row">
<select v-model="wizard.dirMode">
<option value="default">
~/claude-projects/{{ wizard.name.trim() || "…" }}
</option>
<option value="custom">{{ $t("projects.projectDirCustom") }}</option>
</select>
<button
v-if="wizard.dirMode === 'custom'"
type="button"
@click="pickProjectDir"
>
{{ $t("projects.chooseFolder") }}
</button>
</span>
</label>
<label v-if="wizard.dirMode === 'custom' && wizard.dir" class="field">
<span></span>
<span class="icon-path">{{ wizard.dir }}/{{ wizard.name.trim() || "…" }}</span>
</label>
<label class="field"> <label class="field">
{{ $t("projects.pool") }} {{ $t("projects.pool") }}
<select v-model="wizard.pool"> <select v-model="wizard.pool">
@@ -454,6 +518,7 @@ onUnmounted(() => window.clearInterval(timer));
class="primary" class="primary"
:disabled=" :disabled="
wizard.name.trim() === '' || wizard.name.trim() === '' ||
(wizard.dirMode === 'custom' && !wizard.dir) ||
(wizard.workDirMode === 'custom' && !wizard.workDir) (wizard.workDirMode === 'custom' && !wizard.workDir)
" "
> >
@@ -479,10 +544,14 @@ onUnmounted(() => window.clearInterval(timer));
{{ $t("projects.workDirsStay") }} {{ $t("projects.workDirsStay") }}
</p> </p>
</template> </template>
<p class="hint">{{ $t("projects.unlinkHint") }}</p>
<div class="actions"> <div class="actions">
<button type="button" @click="pendingDelete = null"> <button type="button" @click="pendingDelete = null">
{{ $t("projects.cancel") }} {{ $t("projects.cancel") }}
</button> </button>
<button @click="unlinkProject">
{{ $t("projects.unlink") }}
</button>
<button class="danger" @click="confirmDelete"> <button class="danger" @click="confirmDelete">
{{ $t("projects.delete") }} {{ $t("projects.delete") }}
</button> </button>
+16 -2
View File
@@ -51,8 +51,12 @@ const de = {
needsPool: "Kein Pool zugeordnet", needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key", needsKey: "Pool hat keinen API-Key",
newProject: "+ Projekt", newProject: "+ Projekt",
addFolder: "+ Ordner",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
wizardTitle: "Neues Projekt", wizardTitle: "Neues Projekt",
name: "Name", name: "Name",
projectDir: "Projektordner",
projectDirCustom: "Ordner wählen …",
workDir: "Arbeitsordner", workDir: "Arbeitsordner",
workDirNone: "keiner", workDirNone: "keiner",
workDirCustom: "vorhandenen Ordner verknüpfen", workDirCustom: "vorhandenen Ordner verknüpfen",
@@ -64,9 +68,12 @@ const de = {
deleteRunning: "Läuft noch — erst beenden", deleteRunning: "Läuft noch — erst beenden",
deleteTitle: "{name} löschen", deleteTitle: "{name} löschen",
deleteWarning: deleteWarning:
"Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.", "Der Projektordner (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
deleteWorkDirs: "Arbeitsordner mitlöschen:", deleteWorkDirs: "Arbeitsordner mitlöschen:",
workDirsStay: "Die Arbeitsordner bleiben unangetastet.", workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
unlink: "Nur aus Liste nehmen",
unlinkHint:
"Nur aus Liste nehmen entfernt den Eintrag; der Ordner bleibt unangetastet.",
todo: "Todoliste", todo: "Todoliste",
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen", todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
}, },
@@ -166,8 +173,12 @@ const en: typeof de = {
needsPool: "No pool assigned", needsPool: "No pool assigned",
needsKey: "Pool has no API key", needsKey: "Pool has no API key",
newProject: "+ Project", newProject: "+ Project",
addFolder: "+ Folder",
addFolderTitle: "Add an existing folder as a project",
wizardTitle: "New project", wizardTitle: "New project",
name: "Name", name: "Name",
projectDir: "Project folder",
projectDirCustom: "Choose folder …",
workDir: "Working folder", workDir: "Working folder",
workDirNone: "none", workDirNone: "none",
workDirCustom: "link existing folder", workDirCustom: "link existing folder",
@@ -179,9 +190,12 @@ const en: typeof de = {
deleteRunning: "Still running — stop it first", deleteRunning: "Still running — stop it first",
deleteTitle: "Delete {name}", deleteTitle: "Delete {name}",
deleteWarning: deleteWarning:
"The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.", "The project folder (incl. memory/, settings and pool assignment) will be deleted permanently.",
deleteWorkDirs: "Also delete working folders:", deleteWorkDirs: "Also delete working folders:",
workDirsStay: "The working folders stay untouched.", workDirsStay: "The working folders stay untouched.",
unlink: "Remove from list only",
unlinkHint:
"Remove from list only drops the entry; the folder stays untouched.",
todo: "Todo list", todo: "Todo list",
todoDesc: "Inject OFFENE-PUNKTE.md at every session start", todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
}, },