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:
+261
-101
@@ -16,19 +16,22 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
const APIKEY_FILE: &str = "apikey";
|
||||
const POOL_FILE: &str = "pool.json";
|
||||
const PROJECT_FILE: &str = "ai-control.json";
|
||||
const PROJECTS_FILE: &str = "projects.json";
|
||||
|
||||
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
|
||||
struct Paths {
|
||||
pub(crate) struct Paths {
|
||||
home: PathBuf,
|
||||
}
|
||||
|
||||
impl Paths {
|
||||
fn real() -> Self {
|
||||
pub(crate) fn real() -> Self {
|
||||
Paths {
|
||||
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 {
|
||||
self.home.join("claude-projects")
|
||||
}
|
||||
@@ -37,6 +40,10 @@ impl Paths {
|
||||
self.home.join(".config").join("ai-control")
|
||||
}
|
||||
|
||||
fn projects_file(&self) -> PathBuf {
|
||||
self.config_dir().join(PROJECTS_FILE)
|
||||
}
|
||||
|
||||
fn pools_dir(&self) -> PathBuf {
|
||||
self.config_dir().join("pools")
|
||||
}
|
||||
@@ -44,12 +51,93 @@ impl Paths {
|
||||
fn pool_dir(&self, pool: &str) -> PathBuf {
|
||||
self.pools_dir().join(pool)
|
||||
}
|
||||
}
|
||||
|
||||
fn project_config(&self, project: &str) -> PathBuf {
|
||||
self.projects_dir().join(project).join(PROJECT_FILE)
|
||||
// ---------- Projekt-Registry ----------
|
||||
|
||||
/// 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, ®)
|
||||
}
|
||||
|
||||
/// 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, ®)
|
||||
}
|
||||
|
||||
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Project {
|
||||
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()))
|
||||
}
|
||||
|
||||
fn is_git_repo(dir: &PathBuf) -> bool {
|
||||
dir.join(".git").exists()
|
||||
/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
|
||||
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
|
||||
@@ -159,22 +251,45 @@ fn project_state(paths: &Paths) -> Vec<(String, bool)> {
|
||||
state
|
||||
}
|
||||
|
||||
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
|
||||
/// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher).
|
||||
fn sync_session_context(home: &PathBuf, project: &str) {
|
||||
let git_sync = home.join("claude-projects/pool/bin/git-sync");
|
||||
let repo = home.join("claude-projects");
|
||||
match Command::new(&git_sync)
|
||||
.arg(&repo)
|
||||
.arg(format!("session-end sync: {project}"))
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => eprintln!(
|
||||
"session-end sync {project}: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"),
|
||||
/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
|
||||
/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still;
|
||||
/// jeder andere Fehler bricht die Kette mit Meldung ab.
|
||||
fn sync_session_context(repo: &std::path::Path, project: &str) {
|
||||
let run = |args: &[&str]| {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.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() => {}
|
||||
// commit ohne Änderungen: nichts zu syncen, kein Fehler.
|
||||
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()
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("session-end sync {project}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,14 +315,15 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
|
||||
})
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
// Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray
|
||||
// verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
||||
if !ended.is_empty()
|
||||
&& sync_on_session_end(&paths)
|
||||
&& is_git_repo(&paths.projects_dir())
|
||||
{
|
||||
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
|
||||
// der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
||||
if !ended.is_empty() && sync_on_session_end(&paths) {
|
||||
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.
|
||||
@@ -245,24 +361,16 @@ fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
|
||||
|
||||
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||
let mut projects = Vec::new();
|
||||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||||
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();
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
let cfg = read_project_config_in(paths, &name)?;
|
||||
projects.push(Project {
|
||||
running: is_running(&name),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
path: dir.to_string_lossy().into_owned(),
|
||||
pool: cfg.pool,
|
||||
terminal: cfg.terminal,
|
||||
name,
|
||||
});
|
||||
}
|
||||
projects.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
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_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!(
|
||||
"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 {
|
||||
paths
|
||||
.projects_dir()
|
||||
.join(name)
|
||||
.join(".claude")
|
||||
.join("settings.json")
|
||||
fn settings_path(dir: &std::path::Path) -> PathBuf {
|
||||
dir.join(".claude").join("settings.json")
|
||||
}
|
||||
|
||||
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> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(paths, name);
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
if !sp.is_file() {
|
||||
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> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(paths, name);
|
||||
let dir = project_dir(paths, name)?;
|
||||
let sp = settings_path(&dir);
|
||||
if !sp.is_file() {
|
||||
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,
|
||||
serde_json::json!({
|
||||
"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() {
|
||||
fs::write(&todo_path, TODO_SKELETON)
|
||||
.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
|
||||
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
|
||||
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
|
||||
/// der Ordner ist Teil des einen claude-projects-Repos.
|
||||
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
|
||||
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
|
||||
fn create_project_full_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
dir: Option<&str>,
|
||||
pool: Option<&str>,
|
||||
work_dir: Option<&str>,
|
||||
create_work_dir: bool,
|
||||
@@ -383,10 +489,19 @@ fn create_project_full_in(
|
||||
todo: bool,
|
||||
) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = paths.projects_dir().join(name);
|
||||
if dir.exists() {
|
||||
let dir = match dir {
|
||||
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}"));
|
||||
}
|
||||
if dir.exists() {
|
||||
return Err(format!("Ordner existiert bereits: {}", dir.display()));
|
||||
}
|
||||
if let Some(pool) = pool {
|
||||
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
||||
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::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();
|
||||
if let Some(wd) = work_dir {
|
||||
allow.insert(0, format!("Edit({wd}/**)"));
|
||||
additional.push(wd.to_string());
|
||||
}
|
||||
let settings = serde_json::json!({
|
||||
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
|
||||
"autoMemoryDirectory": format!("{contracted}/memory"),
|
||||
"permissions": {
|
||||
"allow": allow,
|
||||
"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())?;
|
||||
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, ®)?;
|
||||
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)?;
|
||||
@@ -433,6 +551,21 @@ fn create_project_full_in(
|
||||
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)]
|
||||
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
@@ -440,17 +573,19 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
if dir.exists() {
|
||||
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.
|
||||
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");
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
if !sp.is_file() {
|
||||
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.
|
||||
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = paths.projects_dir().join(name);
|
||||
let dir = project_dir(paths, name)?;
|
||||
if !dir.is_dir() {
|
||||
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(&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> {
|
||||
let cfg_path = paths.project_config(project);
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
if !cfg_path.is_file() {
|
||||
return Ok(ProjectConfig::default());
|
||||
}
|
||||
@@ -502,7 +642,7 @@ fn write_project_config_in(
|
||||
cfg: &ProjectConfig,
|
||||
) -> Result<(), 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> {
|
||||
@@ -520,7 +660,7 @@ fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
cfg.pool = None;
|
||||
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()));
|
||||
}
|
||||
write_project_config_in(paths, project, &cfg)
|
||||
@@ -542,7 +682,7 @@ fn set_terminal_config_in(
|
||||
.unwrap_or("png")
|
||||
.to_lowercase();
|
||||
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 {
|
||||
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.
|
||||
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('/') {
|
||||
PathBuf::from(icon)
|
||||
Ok(PathBuf::from(icon))
|
||||
} 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 {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = resolve_icon_path(&paths, &project, &icon);
|
||||
let path = resolve_icon_path(&paths, &project, &icon)?;
|
||||
let is_icns = path
|
||||
.extension()
|
||||
.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 {
|
||||
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 out = Command::new("sips")
|
||||
.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.
|
||||
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
||||
let mut users = Vec::new();
|
||||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let cfg_path = entry.path().join(PROJECT_FILE);
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
let cfg_path = dir.join(PROJECT_FILE);
|
||||
if !cfg_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
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())?;
|
||||
if cfg.pool.as_deref() == Some(pool) {
|
||||
users.push(entry.file_name().to_string_lossy().into_owned());
|
||||
users.push(name);
|
||||
}
|
||||
}
|
||||
users.sort();
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
@@ -1219,13 +1356,8 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
|
||||
|
||||
// kodierter Projektpfad → Projektname
|
||||
let mut names = HashMap::new();
|
||||
for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
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);
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
names.insert(encode_project_path(&dir.to_string_lossy()), name);
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
@@ -1291,6 +1423,7 @@ fn list_projects() -> Result<Vec<Project>, String> {
|
||||
#[tauri::command]
|
||||
fn create_project_full(
|
||||
name: String,
|
||||
dir: Option<String>,
|
||||
pool: Option<String>,
|
||||
work_dir: Option<String>,
|
||||
create_work_dir: bool,
|
||||
@@ -1300,6 +1433,7 @@ fn create_project_full(
|
||||
create_project_full_in(
|
||||
&Paths::real(),
|
||||
&name,
|
||||
dir.as_deref(),
|
||||
pool.as_deref(),
|
||||
work_dir.as_deref(),
|
||||
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]
|
||||
fn todo_state(project: String) -> Result<bool, String> {
|
||||
todo_state_in(&Paths::real(), &project)
|
||||
@@ -1520,6 +1675,7 @@ pub fn run() {
|
||||
.icon
|
||||
.map(|i| {
|
||||
resolve_icon_path(&Paths::real(), &project, &i)
|
||||
.expect("Projekt nicht registriert")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
@@ -1655,6 +1811,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_projects,
|
||||
create_project_full,
|
||||
add_project,
|
||||
remove_project,
|
||||
delete_project,
|
||||
project_work_dirs,
|
||||
list_pools,
|
||||
@@ -2040,6 +2198,7 @@ mod tests {
|
||||
create_project_full_in(
|
||||
&p,
|
||||
"neu",
|
||||
None,
|
||||
Some(&kunde),
|
||||
Some("~/projects/neu"),
|
||||
true,
|
||||
@@ -2089,7 +2248,7 @@ mod tests {
|
||||
#[test]
|
||||
fn todo_zuschalten_und_abschalten() {
|
||||
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();
|
||||
assert!(!todo_state_in(&p, "proj").unwrap());
|
||||
|
||||
@@ -2101,7 +2260,7 @@ mod tests {
|
||||
// doppelt aktivieren erzeugt keinen zweiten Hook
|
||||
set_todo_in(&p, "proj", true).unwrap();
|
||||
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();
|
||||
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
||||
@@ -2126,11 +2285,11 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
||||
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");
|
||||
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());
|
||||
assert!(!project_config_path(&p, "neu").unwrap().is_file());
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
|
||||
)
|
||||
@@ -2144,7 +2303,7 @@ mod tests {
|
||||
let p = tmp_paths();
|
||||
create_project_in(&p, "neu").unwrap();
|
||||
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();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
@@ -2156,6 +2315,7 @@ mod tests {
|
||||
&p,
|
||||
"neu",
|
||||
None,
|
||||
None,
|
||||
Some("~/projects/gibtsnicht"),
|
||||
false,
|
||||
TerminalConfig::default(),
|
||||
@@ -2295,7 +2455,7 @@ mod tests {
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
delete_pool_in(&p, &store, &kunde).unwrap();
|
||||
assert!(!p.pool_dir(&kunde).exists());
|
||||
assert!(!p.project_config("proj").exists());
|
||||
assert!(!project_config_path(&p, "proj").unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2360,7 +2520,7 @@ mod tests {
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
|
||||
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();
|
||||
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
|
||||
|
||||
@@ -2382,7 +2542,7 @@ mod tests {
|
||||
create_project_in(&p, "proj").unwrap();
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
unassign_pool_in(&p, "proj").unwrap();
|
||||
assert!(!p.project_config("proj").exists());
|
||||
assert!(!project_config_path(&p, "proj").unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2404,7 +2564,7 @@ mod tests {
|
||||
assign_pool_in(&p, "proj", &privat).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();
|
||||
assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
|
||||
}
|
||||
@@ -2441,7 +2601,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_laesst_arbeitsordner() {
|
||||
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();
|
||||
delete_project_in(&p, "proj", false).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
@@ -2451,7 +2611,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_mit_arbeitsordner() {
|
||||
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();
|
||||
delete_project_in(&p, "proj", true).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
@@ -2461,7 +2621,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
||||
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();
|
||||
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
||||
assert!(delete_project_in(&p, "proj", true).is_err());
|
||||
@@ -2470,7 +2630,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_arbeitsordner_auslesen() {
|
||||
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();
|
||||
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
|
||||
// Projekt ohne settings.json → leer
|
||||
|
||||
Reference in New Issue
Block a user