From b9d006184a1faa62e001264ed414af8ef9b79206 Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Mon, 20 Jul 2026 10:55:21 +0200 Subject: [PATCH] =?UTF-8?q?Projekt-Identit=C3=A4t=20und=20-Config=20neu:?= =?UTF-8?q?=20.ai-control/config.json=20mit=20UUID=20und=20Name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Projekt-Config zieht von ai-control.json nach .ai-control/config.json um, das Icon liegt daneben und synct mit dem Projekt. - Jedes Projekt trägt eine UUID (id) und den Anzeigenamen (name) in der Config; die UUID ist Registry-Schlüssel und project-Parameter aller Commands, der Name reine Darstellung. - Pool-Zuordnung ist maschinenlokal und liegt in der projects.json-Registry, nicht mehr in der syncbaren Projekt-Config. - open_terminal prüft die ID im Ordner gegen die Registry (verschobene oder ersetzte Ordner scheitern laut). - Import übernimmt eine mitgebrachte ID, legt Arbeitsordner und Archiv-Home an und zieht die Archiv-Permission nach. - Migration beim App-Start: Alt-Datei umziehen, Pool in die Registry, Icons aus ~/.config/ai-control/icons ins Projekt, Registry auf UUIDs. - ProjectConfig reicht unbekannte Keys per serde(flatten) durch den Round-Trip (Regressionstest config_roundtrip_erhaelt_fremde_keys). --- README.md | 12 +- src-tauri/src/app.rs | 142 ++--- src-tauri/src/commands.rs | 42 +- src-tauri/src/domain/archive.rs | 17 +- src-tauri/src/domain/paths.rs | 4 +- src-tauri/src/domain/pool.rs | 20 +- src-tauri/src/domain/project.rs | 666 ++++++++++++++++++------ src-tauri/src/domain/registry.rs | 93 +++- src-tauri/src/domain/testutil.rs | 11 +- src-tauri/src/domain/todo.rs | 17 +- src-tauri/src/domain/usage.rs | 5 +- src-tauri/src/domain/watcher.rs | 10 +- src-tauri/src/lib.rs | 6 +- src-tauri/src/platform/linux/desktop.rs | 17 +- src-tauri/src/platform/macos.rs | 4 +- src-tauri/src/platform/windows.rs | 4 +- src-tauri/src/terminal.rs | 31 +- src/components/Popup.vue | 13 +- src/components/ProjectList.vue | 57 +- src/terminal.ts | 5 +- 20 files changed, 830 insertions(+), 346 deletions(-) diff --git a/README.md b/README.md index 2a8bfa9..2295be7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Running Claude Code with multiple accounts or credential sets means hitting the The app ships an MCP stdio server (`ai-control --mcp-panel`, server key `text-panel`) and provisions it into every pool, including the tool permission — no per-pool setup: - **`write_panel`** — Claude places a Markdown draft in the panel next to the terminal instead of printing it as chat prose. -- **`archive_panel`** — saves the current draft as a Markdown file into the project's archive directory (`archiveDir` in the project's `ai-control.json`). +- **`archive_panel`** — saves the current draft as a Markdown file into the project's archive directory (`archiveHome` in the project's `.ai-control/config.json`). The panel docks into the terminal window and can be detached into its own window; the title is taken from the draft's first heading and can be edited, the content is editable too. Spell checking follows `spellcheckLang` (per-text override in the panel). @@ -123,14 +123,18 @@ Prerequisites: Rust (stable), Node.js/npm. `dev.sh`/`build.sh` expect `CARGO_HOM ``` ~/.config/ai-control/ ├── settings.json app settings (see below) -├── projects.json project registry: name → directory +├── projects.json project registry: project id → directory +│ + pool assignment (machine-local) └── pools// one Claude config directory per pool └── pool.json name + credential type / any path, mapped via the registry ├── .claude/ Claude Code project configuration -└── ai-control.json pool assignment, terminal settings, - panel archive directory (archiveDir) +└── .ai-control/ syncs with the project + ├── config.json project id (UUID) + display name, + │ terminal settings, panel archive + │ directory (archiveHome) + └── icon.png project icon ``` ### settings.json diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 4fe944e..a162d27 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -4,7 +4,7 @@ use crate::domain::paths::Paths; use crate::domain::pool::provision_pools_for_panel; -use crate::domain::project::terminal_config; +use crate::domain::project::project_config; use crate::domain::watcher::spawn_session_watcher; use crate::platform::Anchor; use crate::{commands, terminal}; @@ -118,6 +118,10 @@ pub(crate) fn main_builder() -> tauri::Builder { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); + // Alt-Layout migrieren: ai-control.json → .ai-control/config.json, + // Pool-Zuordnung in die Registry, Icons in den Projekt-Config-Ordner. + crate::domain::project::migrate_layout_in(&Paths::real())?; + // Session-Watcher: synct bei Session-Ende (Prozess verschwindet). spawn_session_watcher(); @@ -232,44 +236,72 @@ pub(crate) fn main_builder() -> tauri::Builder { } } }) - .invoke_handler(tauri::generate_handler![ - commands::list_projects, - commands::create_project_full, - commands::add_project, - commands::remove_project, - commands::delete_project, - commands::project_work_dirs, - commands::set_project_dir, - commands::add_work_dir, - commands::remove_work_dir, - commands::list_pools, - commands::create_oauth_pool, - commands::create_apikey_pool, - commands::rename_pool, - commands::delete_pool, - commands::assign_pool, - commands::unassign_pool, - commands::set_terminal_config, - commands::project_icon, - commands::pool_label, - commands::todo_state, - commands::set_todo, - commands::usage_stats, - commands::stop_project, - commands::restart_project, - commands::start_or_focus_cmd, - commands::open_main_window, - commands::quit_app, - commands::sync_setting, - commands::set_sync_setting, - commands::terminal_font_size, - commands::set_terminal_font_size, - commands::link_pool_runtime, - commands::oauth_login, - commands::keychain_status, - commands::set_apikey, - terminal::open_terminal - ]) + .invoke_handler(invoke_handlers()) +} + +/// Eine Kommando-Liste für beide Prozesse (Hauptfenster und Terminal). +/// Zwei getrennte Listen hatten den Settings-Dialog gebrochen, weil die +/// Archiv-Kommandos nur im Terminal-Prozess registriert waren. +fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { + tauri::generate_handler![ + commands::list_projects, + commands::create_project_full, + commands::add_project, + commands::remove_project, + commands::delete_project, + commands::project_work_dirs, + commands::set_project_dir, + commands::add_work_dir, + commands::remove_work_dir, + commands::list_pools, + commands::create_oauth_pool, + commands::create_apikey_pool, + commands::rename_pool, + commands::delete_pool, + commands::assign_pool, + commands::unassign_pool, + commands::set_terminal_config, + commands::project_icon, + commands::pool_label, + commands::todo_state, + commands::set_todo, + commands::usage_stats, + commands::stop_project, + commands::restart_project, + commands::start_or_focus_cmd, + commands::open_main_window, + commands::quit_app, + commands::sync_setting, + commands::set_sync_setting, + commands::terminal_font_size, + commands::set_terminal_font_size, + commands::link_pool_runtime, + commands::oauth_login, + commands::keychain_status, + commands::set_apikey, + commands::panel_archive_dir_cmd, + commands::panel_archive_cmd, + commands::set_archive_home_cmd, + commands::clear_archive_home_cmd, + commands::archive_docs_cmd, + commands::reveal_path_cmd, + commands::spellcheck_lang, + terminal::open_terminal, + terminal::term_start, + terminal::term_log, + terminal::term_write, + terminal::term_resize, + terminal::panel_read, + terminal::commands_read, + terminal::commands_delete, + terminal::panel_set, + terminal::search_read, + terminal::search_run, + terminal::panel_load, + terminal::wiki_read, + terminal::wiki_open, + terminal::open_panel_window + ] } /// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt @@ -280,7 +312,7 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder { .plugin(tauri_plugin_clipboard_manager::init()) .manage(terminal::Terminals::default()) .setup(move |app| { - let cfg = terminal_config(&project)?; + let cfg = project_config(&project)?; terminal::build_window(app.handle(), &project, &cfg)?; Ok(()) }) @@ -297,33 +329,5 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder { } } }) - .invoke_handler(tauri::generate_handler![ - terminal::term_start, - terminal::term_log, - terminal::term_write, - terminal::term_resize, - terminal::panel_read, - terminal::commands_read, - terminal::commands_delete, - terminal::panel_set, - terminal::search_read, - terminal::search_run, - terminal::panel_load, - terminal::wiki_read, - terminal::wiki_open, - terminal::open_panel_window, - commands::panel_archive_dir_cmd, - commands::panel_archive_cmd, - commands::set_archive_home_cmd, - commands::clear_archive_home_cmd, - commands::archive_docs_cmd, - commands::reveal_path_cmd, - // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. - commands::list_projects, - commands::project_icon, - commands::pool_label, - commands::terminal_font_size, - commands::set_terminal_font_size, - commands::spellcheck_lang - ]) + .invoke_handler(invoke_handlers()) } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ab5ce07..3c4d593 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,9 +14,9 @@ use crate::domain::pool::{ }; use crate::domain::project::{ add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_project_in, - is_running, kill_terminals, list_projects_in, project_work_dirs_in, read_project_config_in, - remove_work_dir_in, resolve_icon_path, running_projects_using_pool, set_project_dir_in, - set_terminal_config_in, unassign_pool_in, Project, TerminalConfig, + display_name_in, is_running, kill_terminals, list_projects_in, project_work_dirs_in, + read_project_config_in, remove_work_dir_in, resolve_icon_path, running_projects_using_pool, + set_project_dir_in, set_terminal_config_in, unassign_pool_in, Project, TerminalConfig, }; use crate::domain::registry::unregister_project; use crate::domain::settings; @@ -41,7 +41,7 @@ pub(crate) fn create_project_full( create_work_dir: bool, terminal: TerminalConfig, todo: bool, -) -> Result<(), String> { +) -> Result { create_project_full_in( &Paths::real(), &name, @@ -62,18 +62,20 @@ pub(crate) fn add_project(path: String) -> Result<(), String> { /// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet. #[tauri::command] -pub(crate) fn remove_project(name: String) -> Result<(), String> { - if is_running(&name) { +pub(crate) fn remove_project(project: String) -> Result<(), String> { + if is_running(&project) { + let name = display_name_in(&Paths::real(), &project)?; return Err(format!("{name} läuft noch — erst beenden")); } - unregister_project(&Paths::real(), &name) + unregister_project(&Paths::real(), &project) } /// Projektordner neu zuordnen; bei laufender Session gesperrt. #[tauri::command] pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> { if is_running(&project) { - return Err(format!("{project} läuft noch — erst beenden")); + let name = display_name_in(&Paths::real(), &project)?; + return Err(format!("{name} läuft noch — erst beenden")); } set_project_dir_in(&Paths::real(), &project, &dir) } @@ -89,11 +91,12 @@ pub(crate) fn remove_work_dir(project: String, dir: String) -> Result<(), String } #[tauri::command] -pub(crate) fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> { - if is_running(&name) { +pub(crate) fn delete_project(project: String, delete_work_dirs: bool) -> Result<(), String> { + if is_running(&project) { + let name = display_name_in(&Paths::real(), &project)?; return Err(format!("{name} läuft noch — erst beenden")); } - delete_project_in(&Paths::real(), &name, delete_work_dirs) + delete_project_in(&Paths::real(), &project, delete_work_dirs) } #[tauri::command] @@ -133,7 +136,7 @@ pub(crate) fn project_icon(project: String) -> Result, String> { let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else { return Ok(None); }; - let path = resolve_icon_path(&paths, &icon); + let path = resolve_icon_path(&paths, &project, &icon)?; let mime = match path.extension().and_then(|e| e.to_str()) { Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml", _ => "image/png", @@ -168,6 +171,11 @@ pub(crate) fn rename_pool(pool: String, name: String) -> Result<(), String> { rename_pool_in(&Paths::real(), &pool, &name) } +/// Anzeige-Namen der Projekte (für Fehlermeldungen mit Projektliste). +fn display_names(paths: &Paths, ids: &[String]) -> Result, String> { + ids.iter().map(|id| display_name_in(paths, id)).collect() +} + #[tauri::command] pub(crate) fn delete_pool(pool: String) -> Result<(), String> { let paths = Paths::real(); @@ -175,7 +183,7 @@ pub(crate) fn delete_pool(pool: String) -> Result<(), String> { if !running.is_empty() { return Err(format!( "Pool wird noch benutzt — läuft: {}", - running.join(", ") + display_names(&paths, &running)?.join(", ") )); } delete_pool_in(&paths, &KeychainStore, &pool) @@ -219,7 +227,7 @@ pub(crate) fn oauth_login(pool: String) -> Result<(), String> { if !running.is_empty() { return Err(format!( "Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}", - running.join(", ") + display_names(&paths, &running)?.join(", ") )); } @@ -246,7 +254,8 @@ pub(crate) fn link_pool_runtime(pool: String) -> Result<(), String> { #[tauri::command] pub(crate) fn stop_project(project: String) -> Result<(), String> { if crate::platform::terminal_pids(&project).is_empty() { - return Err(format!("{project} läuft nicht")); + let name = display_name_in(&Paths::real(), &project)?; + return Err(format!("{name} läuft nicht")); } kill_terminals(&project) } @@ -259,7 +268,8 @@ pub(crate) fn restart_project(app: tauri::AppHandle, project: String) -> Result< let deadline = Instant::now() + Duration::from_secs(30); while is_running(&project) { if Instant::now() > deadline { - return Err(format!("{project} hat sich nach 30 s nicht beendet")); + let name = display_name_in(&Paths::real(), &project)?; + return Err(format!("{name} hat sich nach 30 s nicht beendet")); } std::thread::sleep(Duration::from_millis(250)); } diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index c80648c..0d91ae1 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -11,7 +11,7 @@ use crate::domain::project::{ }; use crate::domain::registry::project_dir; -/// Konfiguriertes Archiv-Home des Projekts (ai-control.json: archiveHome), +/// Konfiguriertes Archiv-Home des Projekts (config.json: archiveHome), /// Home-expandiert; None, wenn nicht gesetzt. pub(crate) fn project_archive_home(project: &str) -> Option { let paths = Paths::real(); @@ -27,7 +27,7 @@ pub(crate) fn require_archive_home(project: &str) -> Result { Ok(home) } -/// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in +/// Setzt das Archiv-Home: config.json (~-relativ) und ein Eintrag in /// permissions.additionalDirectories + Edit der Projekt-settings.json, damit /// claude das Archiv später lesen/scannen darf. pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> { @@ -53,7 +53,12 @@ pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), S } /// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein. -fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> { +/// Auch beim Projekt-Import im Einsatz (mitgebrachtes archiveHome). +pub(crate) fn add_archive_permission( + paths: &Paths, + project: &str, + dir: &str, +) -> Result<(), String> { let sp = settings_path(&project_dir(paths, project)?); let mut v: serde_json::Value = if sp.is_file() { serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?) @@ -90,7 +95,7 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) } -/// Wählt das Archiv ab: archiveHome aus der ai-control.json entfernen und die +/// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die /// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus /// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen. pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> { @@ -171,7 +176,9 @@ pub(crate) fn archive_panel_content( let (stamp, iso) = utc_stamp(secs); let title = first_line(&text); let path = free_path(&dir, &stamp, &slugify(&title)); - let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end()); + // Frontmatter trägt den Anzeigenamen, nicht die Projekt-ID. + let name = crate::domain::project::display_name_in(&Paths::real(), project)?; + let doc = format!("{}{}\n", frontmatter(&title, &name, &iso, meta), text.trim_end()); fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; Ok(path) } diff --git a/src-tauri/src/domain/paths.rs b/src-tauri/src/domain/paths.rs index 85bc677..8078478 100644 --- a/src-tauri/src/domain/paths.rs +++ b/src-tauri/src/domain/paths.rs @@ -33,8 +33,8 @@ impl Paths { self.config_dir().join("pools") } - /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config, - /// unabhängig von Pool-Zuordnung und Quell-Repos. + /// Ehemals gemeinsames Icons-Verzeichnis aller Projekte — Icons liegen jetzt + /// im .ai-control/-Ordner des Projekts; hiervon liest nur noch die Migration. pub(crate) fn icons_dir(&self) -> PathBuf { self.config_dir().join("icons") } diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index e5ecda2..80a0fdc 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -80,8 +80,16 @@ pub(crate) fn list_pools_in( } _ => true, }; - let projects = projects_using_pool(paths, &id)?; - let running = projects.iter().filter(|p| is_running(p)).cloned().collect(); + // Anzeige-Namen fürs Frontend; die Lauf-Erkennung braucht die IDs. + let mut projects = Vec::new(); + let mut running = Vec::new(); + for project in projects_using_pool(paths, &id)? { + let name = crate::domain::project::display_name_in(paths, &project)?; + if is_running(&project) { + running.push(name.clone()); + } + projects.push(name); + } pools.push(PoolInfo { id, projects, @@ -486,6 +494,7 @@ mod tests { use crate::domain::project::{ assign_pool_in, read_project_config_in, set_terminal_config_in, TerminalConfig, }; + use crate::domain::registry::project_pool; use crate::domain::settings::APP_SETTINGS_FILE; use crate::domain::testutil::{ create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore, @@ -625,8 +634,7 @@ mod tests { assert_eq!(pool.name, "kunde-neu"); assert_eq!(pool.credential_type, "apikey"); assert!(p.pool_dir(&id).is_dir()); - let cfg = read_project_config_in(&p, "proj").unwrap(); - assert_eq!(cfg.pool.as_deref(), Some(id.as_str())); + assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(id.as_str())); } #[test] @@ -772,7 +780,7 @@ mod tests { assign_pool_in(&p, "proj", &kunde).unwrap(); delete_pool_in(&p, &store, &kunde).unwrap(); assert!(!p.pool_dir(&kunde).exists()); - assert!(!crate::domain::project::project_config_path(&p, "proj").unwrap().exists()); + assert_eq!(project_pool(&p, "proj").unwrap(), None); } #[test] @@ -824,8 +832,8 @@ mod tests { ) .unwrap(); delete_pool_in(&p, &store, &kunde).unwrap(); + assert_eq!(project_pool(&p, "proj").unwrap(), None); let cfg = read_project_config_in(&p, "proj").unwrap(); - assert_eq!(cfg.pool, None); assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); } diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs index a7c43a8..0510034 100644 --- a/src-tauri/src/domain/project.rs +++ b/src-tauri/src/domain/project.rs @@ -1,21 +1,38 @@ //! Projekte: Anlage (Wizard), Registry-Aufnahme, Arbeitsordner, Pool-Zuordnung, //! Terminal-Einstellungen, Löschen. Der Projektordner trägt .claude/settings.json -//! und die ai-control.json (Pool + Terminal-Config). +//! und .ai-control/ (config.json + Icon — synct mit dem Projekt); die +//! Pool-Zuordnung ist maschinenlokal und liegt in der Registry. +//! +//! Identität: Jedes Projekt hat eine UUID (`id` in der config.json). Sie ist +//! der Registry-Schlüssel und der `project`-Parameter aller Commands; der +//! Anzeigename (`name`) ist reine Darstellung. Registry-Eintrag und Ordner +//! sind damit gegeneinander prüfbar (`verify_project_dir_in`). use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use crate::domain::check_name; use crate::domain::paths::{contract_home, expand_home, Paths}; use crate::domain::pool::POOL_FILE; -use crate::domain::registry::{load_registry, project_dir, register_project, save_registry}; +use crate::domain::registry::{ + load_registry, project_dir, project_pool, register_project, save_registry, set_project_pool, + RegEntry, +}; -/// Projekt-Config im Projektordner (Pool-Zuordnung + Terminal-Einstellungen). -pub(crate) const PROJECT_FILE: &str = "ai-control.json"; +/// Projekt-eigener Config-Ordner im Projekt-Root: config.json + Icon. +pub(crate) const PROJECT_CONFIG_DIR: &str = ".ai-control"; + +/// Projekt-Config im Config-Ordner (Terminal-Einstellungen + Archiv-Home). +pub(crate) const PROJECT_FILE: &str = "config.json"; + +/// Alt-Layout: einzelne Datei im Projekt-Root; wird beim App-Start migriert. +const LEGACY_PROJECT_FILE: &str = "ai-control.json"; #[derive(Serialize)] pub(crate) struct Project { + pub(crate) id: String, pub(crate) name: String, pub(crate) path: String, pub(crate) pool: Option, @@ -25,8 +42,13 @@ pub(crate) struct Project { #[derive(Serialize, Deserialize, Default)] pub(crate) struct ProjectConfig { + /// Projekt-UUID — Registry-Schlüssel; synct mit dem Projekt und macht den + /// Registry-Eintrag gegen den Ordner prüfbar. #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) pool: Option, + pub(crate) id: Option, + /// Anzeigename (Projektliste, Fenstertitel-Fallback, .desktop-Name). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) name: Option, #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")] pub(crate) terminal: TerminalConfig, /// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen @@ -72,7 +94,7 @@ pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf { } pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result { - Ok(project_dir(paths, project)?.join(PROJECT_FILE)) + Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE)) } pub(crate) fn read_project_config_in( @@ -94,7 +116,9 @@ pub(crate) fn write_project_config_in( cfg: &ProjectConfig, ) -> Result<(), String> { let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?; - let path = project_config_path(paths, project)?; + let dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR); + fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let path = dir.join(PROJECT_FILE); fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) } @@ -122,65 +146,91 @@ pub(crate) fn running_projects_using_pool( .collect()) } -/// Namen aller Projekte, die `pool` zugeordnet haben. +/// Namen aller Projekte, denen die Registry `pool` zuordnet. pub(crate) fn projects_using_pool(paths: &Paths, pool: &str) -> Result, String> { - let mut users = Vec::new(); - 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| format!("{}: {e}", cfg_path.display()))?; - let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?; - if cfg.pool.as_deref() == Some(pool) { - users.push(name); - } - } - Ok(users) + Ok( + load_registry(paths)? + .into_iter() + .filter(|(_, e)| e.pool.as_deref() == Some(pool)) + .map(|(name, _)| name) + .collect(), + ) } pub(crate) fn list_projects_in(paths: &Paths) -> Result, String> { let mut projects = Vec::new(); - for (name, dir) in load_registry(paths)? { - let cfg = read_project_config_in(paths, &name)?; + for (id, entry) in load_registry(paths)? { + let cfg = read_project_config_in(paths, &id)?; projects.push(Project { - running: is_running(&name), - path: contract_home(paths, &dir), - pool: cfg.pool, + running: is_running(&id), + path: contract_home(paths, &entry.dir), + pool: entry.pool, + name: cfg.name.unwrap_or_else(|| id.clone()), terminal: cfg.terminal, - name, + id, }); } Ok(projects) } +/// Anzeigename eines Projekts; Fallback ist die ID (Projekte vor der Migration). +pub(crate) fn display_name_in(paths: &Paths, project: &str) -> Result { + Ok( + read_project_config_in(paths, project)? + .name + .unwrap_or_else(|| project.to_string()), + ) +} + +/// Prüft Registry-Eintrag gegen den Ordner: die config.json unter dem +/// registrierten Pfad muss die Projekt-ID tragen. Liefert den Ordner. +pub(crate) fn verify_project_dir_in(paths: &Paths, project: &str) -> Result { + let dir = project_dir(paths, project)?; + let cfg = read_project_config_in(paths, project)?; + if cfg.id.as_deref() != Some(project) { + return Err(format!( + "Projektordner passt nicht zur Registry: {} trägt die ID {}, registriert ist {project}", + dir.display(), + cfg.id.as_deref().unwrap_or("(keine)"), + )); + } + Ok(dir) +} + /// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als /// CLAUDE_CONFIG_DIR mitgegeben. /// -/// Der Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon tut: -/// Die Quelle ist die `ai-control.json` **im Projektordner**, also eine -/// versionierte Datei, die mit einem geklonten Repo hereinkommt. Ungeprüft -/// bestimmte sie mit `../…` ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR — -/// und dessen `settings.json` trägt `apiKeyHelper`, ein Kommando, das beim -/// Sessionstart ausgeführt wird. +/// Der Pool-Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon +/// tut: Die Migration übernimmt Pool-Einträge aus der alten, versionierten +/// ai-control.json eines geklonten Repos. Ungeprüft bestimmte ein `../…` +/// daraus ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR — und dessen +/// `settings.json` trägt `apiKeyHelper`, ein Kommando, das beim Sessionstart +/// ausgeführt wird. pub(crate) fn project_pool_dir(project: &str) -> Result, String> { - let paths = Paths::real(); - let Some(pool) = read_project_config_in(&paths, project)?.pool else { + project_pool_dir_in(&Paths::real(), project) +} + +pub(crate) fn project_pool_dir_in( + paths: &Paths, + project: &str, +) -> Result, String> { + let Some(pool) = project_pool(paths, project)? else { return Ok(None); }; - check_name(&pool).map_err(|_| format!("ungültiger Pool in {PROJECT_FILE}: {pool}"))?; + check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?; Ok(Some(paths.pool_dir(&pool))) } -/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. -pub(crate) fn terminal_config(project: &str) -> Result { - Ok(read_project_config_in(&Paths::real(), project)?.terminal) +/// Projekt-Config für den Terminal-Prozess (Terminal-Einstellungen + Name). +pub(crate) fn project_config(project: &str) -> Result { + read_project_config_in(&Paths::real(), project) } /// 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, Registry-Eintrag. -/// Ohne Zielordner landet das Projekt unter ~/claude-projects/. +/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit +/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter +/// ~/claude-projects/. Liefert die neue Projekt-ID. #[allow(clippy::too_many_arguments)] pub(crate) fn create_project_full_in( paths: &Paths, @@ -191,14 +241,14 @@ pub(crate) fn create_project_full_in( create_work_dir: bool, terminal: TerminalConfig, todo: bool, -) -> Result<(), String> { +) -> Result { check_name(name)?; let dir = match dir { Some(d) => expand_home(paths, d), None => paths.projects_dir().join(name), }; let mut reg = load_registry(paths)?; - if reg.contains_key(name) { + if list_projects_in(paths)?.iter().any(|p| p.name == name) { return Err(format!("Projekt existiert bereits: {name}")); } if dir.exists() { @@ -245,21 +295,22 @@ pub(crate) fn create_project_full_in( fs::write(dir.join(".claude").join("settings.json"), raw + "\n") .map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?; - reg.insert(name.to_string(), dir.clone()); + let id = uuid::Uuid::new_v4().to_string(); + reg.insert(id.clone(), RegEntry { dir: dir.clone(), pool: pool.map(str::to_string) }); save_registry(paths, ®)?; let cfg = ProjectConfig { - pool: pool.map(str::to_string), + id: Some(id.clone()), + name: Some(name.to_string()), terminal, - ..ProjectConfig::default() + archive_home: None, + rest: Default::default(), }; - if cfg.pool.is_some() || !cfg.terminal.is_empty() { - write_project_config_in(paths, name, &cfg)?; - } + write_project_config_in(paths, &id, &cfg)?; if todo { - crate::domain::todo::set_todo_in(paths, name, true)?; + crate::domain::todo::set_todo_in(paths, &id, true)?; } - crate::platform::write_terminal_desktop(paths, name, &cfg.terminal); - Ok(()) + crate::platform::write_terminal_desktop(paths, &id, &cfg); + Ok(id) } /// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise @@ -273,14 +324,14 @@ pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result return Err(format!("Ordner nicht gefunden: {}", new_dir.display())); } let mut reg = load_registry(paths)?; - let old_dir = reg - .get(name) - .cloned() + let entry = reg + .get_mut(name) .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?; + let old_dir = entry.dir.clone(); if old_dir == new_dir { return Ok(()); } - reg.insert(name.to_string(), new_dir.clone()); + entry.dir = new_dir.clone(); save_registry(paths, ®)?; let sp = settings_path(&new_dir); @@ -309,23 +360,51 @@ pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) } -/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername. +/// Nimmt einen bestehenden Ordner als Projekt auf. Eine mitgebrachte +/// .ai-control/config.json liefert ID und Name (Sync-Fall: dasselbe Projekt +/// behält seine ID auf jeder Maschine); ohne Config entsteht eine neue ID mit +/// dem Ordnernamen als Name. Mitgebrachte Pfade werden geprüft und angelegt: +/// Arbeitsordner aus der settings.json sowie das Archiv-Home samt +/// Permissions-Eintrag. pub(crate) 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 + let dirname = dir .file_name() .ok_or_else(|| format!("kein Ordnername: {}", dir.display()))? .to_string_lossy() .into_owned(); - check_name(&name)?; - register_project(paths, &name, &dir)?; - let cfg = read_project_config_in(paths, &name) - .map(|c| c.terminal) - .unwrap_or_default(); - crate::platform::write_terminal_desktop(paths, &name, &cfg); + check_name(&dirname)?; + + let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE); + let mut cfg: ProjectConfig = if cfg_path.is_file() { + let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))? + } else { + ProjectConfig::default() + }; + if let Some(id) = cfg.id.as_deref() { + if load_registry(paths)?.contains_key(id) { + return Err(format!("Projekt ist schon registriert: {id}")); + } + } + let id = cfg.id.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone(); + cfg.name.get_or_insert(dirname); + register_project(paths, &id, &dir)?; + write_project_config_in(paths, &id, &cfg)?; + + for wd in project_work_dirs_in(paths, &id)? { + let wd_path = expand_home(paths, &wd); + fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; + } + if let Some(archive) = cfg.archive_home.as_deref() { + let expanded = expand_home(paths, archive); + fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?; + crate::domain::archive::add_archive_permission(paths, &id, archive)?; + } + crate::platform::write_terminal_desktop(paths, &id, &cfg); Ok(()) } @@ -449,21 +528,15 @@ pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result if !paths.pool_dir(pool).join(POOL_FILE).is_file() { return Err(format!("Pool existiert nicht: {pool}")); } - let mut cfg = read_project_config_in(paths, project)?; - cfg.pool = Some(pool.to_string()); - write_project_config_in(paths, project, &cfg) + set_project_pool(paths, project, Some(pool)) } -/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch -/// Terminal-Einstellungen trägt. +/// Nimmt die Pool-Zuordnung aus der Registry. pub(crate) 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() && cfg.archive_home.is_none() { - let cfg_path = project_config_path(paths, project)?; - return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())); + if project_pool(paths, project)?.is_none() { + return Err(format!("kein Pool zugeordnet: {project}")); } - write_project_config_in(paths, project, &cfg) + set_project_pool(paths, project, None) } pub(crate) fn set_terminal_config_in( @@ -472,8 +545,8 @@ pub(crate) fn set_terminal_config_in( mut terminal: TerminalConfig, ) -> Result<(), String> { let mut cfg = read_project_config_in(paths, project)?; - // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als - // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo. + // Absolut gewählte Icons in den Projekt-Config-Ordner kopieren und als + // Dateiname speichern — das Icon synct damit mit dem Projekt. if let Some(icon) = terminal.icon.as_deref() { if icon.starts_with('/') { let src = PathBuf::from(icon); @@ -482,10 +555,10 @@ pub(crate) fn set_terminal_config_in( .and_then(|e| e.to_str()) .unwrap_or("png") .to_lowercase(); - let name = format!("{project}.{ext}"); - let icons = paths.icons_dir(); - fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?; - let dest = icons.join(&name); + let name = format!("icon.{ext}"); + let cfg_dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR); + fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; + let dest = cfg_dir.join(&name); if src != dest { fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; } @@ -497,67 +570,126 @@ pub(crate) fn set_terminal_config_in( terminal.rest = std::mem::take(&mut cfg.terminal.rest); cfg.terminal = terminal; write_project_config_in(paths, project, &cfg)?; - crate::platform::write_terminal_desktop(paths, project, &cfg.terminal); + crate::platform::write_terminal_desktop(paths, project, &cfg); Ok(()) } /// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im -/// gemeinsamen Icons-Verzeichnis der App. -pub(crate) fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf { +/// Projekt-Config-Ordner (.ai-control) des Projekts. +pub(crate) fn resolve_icon_path( + paths: &Paths, + project: &str, + icon: &str, +) -> Result { if icon.starts_with('/') { - PathBuf::from(icon) + Ok(PathBuf::from(icon)) } else { - paths.icons_dir().join(icon) + Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(icon)) } } +/// Migration beim App-Start (idempotent): ai-control.json aus dem Projekt-Root +/// nach .ai-control/config.json, die Pool-Zuordnung daraus in die Registry, +/// Projekt-Icons aus dem zentralen Icons-Verzeichnis in den Projekt-Config-Ordner. +/// Namens-Schlüssel der Registry werden auf die Projekt-UUID umgestellt; fehlt +/// die in der config.json, entsteht sie hier (Name = bisheriger Schlüssel). +pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> { + let reg = load_registry(paths)?; + let mut new_reg: BTreeMap = BTreeMap::new(); + let mut reg_dirty = false; + for (key, mut entry) in reg { + let legacy = entry.dir.join(LEGACY_PROJECT_FILE); + if legacy.is_file() { + let raw = fs::read_to_string(&legacy).map_err(|e| format!("{}: {e}", legacy.display()))?; + let mut v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", legacy.display()))?; + let obj = v.as_object_mut().ok_or_else(|| format!("{}: kein Objekt", legacy.display()))?; + if let Some(pool) = obj.remove("pool").and_then(|p| p.as_str().map(str::to_string)) { + if entry.pool.is_none() { + entry.pool = Some(pool); + reg_dirty = true; + } + } + let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR); + let cfg_path = cfg_dir.join(PROJECT_FILE); + if !cfg_path.exists() && !obj.is_empty() { + fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?; + } + fs::remove_file(&legacy).map_err(|e| format!("{}: {e}", legacy.display()))?; + } + + // Config lesen; ID und Name sicherstellen. Der bisherige Registry-Schlüssel + // war der Anzeigename. + let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR); + let cfg_path = cfg_dir.join(PROJECT_FILE); + let mut cfg: ProjectConfig = if cfg_path.is_file() { + let raw = + fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))? + } else { + ProjectConfig::default() + }; + let mut cfg_dirty = cfg.id.is_none() || cfg.name.is_none(); + let id = cfg.id.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone(); + cfg.name.get_or_insert_with(|| key.clone()); + + // Icon aus ~/.config/ai-control/icons/ in den Projekt-Config-Ordner + // (Kopie + Löschen statt rename — Icons-Verzeichnis und Projekt können auf + // verschiedenen Dateisystemen liegen). + if let Some(icon) = cfg.terminal.icon.clone() { + if !icon.starts_with('/') { + let old = paths.icons_dir().join(&icon); + if old.is_file() { + let ext = std::path::Path::new(&icon) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + let name = format!("icon.{ext}"); + fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; + fs::copy(&old, cfg_dir.join(&name)).map_err(|e| format!("{}: {e}", old.display()))?; + fs::remove_file(&old).map_err(|e| format!("{}: {e}", old.display()))?; + cfg.terminal.icon = Some(name); + cfg_dirty = true; + } + } + } + + if cfg_dirty { + fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; + let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; + fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?; + } + if id != key { + reg_dirty = true; + } + new_reg.insert(id, entry); + } + if reg_dirty { + save_registry(paths, &new_reg)?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths}; use crate::domain::todo::TODO_FILE; - /// Der read-modify-write darf keine Keys verlieren, die dieser Build nicht - /// kennt. Genau daran ging `archiveHome` verloren: Ein älterer Build ohne das - /// Feld hat bei der Pool-Zuweisung die ganze Datei neu geschrieben. + /// Ein Pool-Eintrag mit Pfad-Bestandteilen — etwa per Migration aus der + /// mitgeklonten Alt-Datei in die Registry gelangt — darf kein beliebiges + /// Verzeichnis zum CLAUDE_CONFIG_DIR machen. #[test] - fn pool_zuweisen_erhaelt_fremde_keys() { - let p = tmp_paths(); - let pool = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project(&p, "proj").unwrap(); - let cfg_path = project_config_path(&p, "proj").unwrap(); - fs::write( - &cfg_path, - r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1}, - "terminal":{"theme":"monokai","fontSize":13}}"#, - ) - .unwrap(); - - assign_pool_in(&p, "proj", &pool).unwrap(); - - let raw = fs::read_to_string(&cfg_path).unwrap(); - let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); - assert_eq!(v["archiveHome"], "~/archiv"); - assert_eq!(v["zukunftsfeld"]["a"], 1); - assert_eq!(v["terminal"]["theme"], "monokai"); - // Auch im verschachtelten terminal-Block, der als Ganzes mitgeschrieben wird. - assert_eq!(v["terminal"]["fontSize"], 13); - assert_eq!(v["pool"], serde_json::Value::String(pool)); - } - - /// Ein Pool-Eintrag aus einer mitgeklonten `ai-control.json` darf kein - /// beliebiges Verzeichnis zum CLAUDE_CONFIG_DIR machen. - #[test] - fn pool_aus_projektdatei_mit_traversal_wird_abgelehnt() { + fn pool_mit_traversal_wird_abgelehnt() { let p = tmp_paths(); create_project(&p, "fremd").unwrap(); - let cfg_path = project_config_path(&p, "fremd").unwrap(); - fs::write(&cfg_path, r#"{"pool":"../../../../tmp/x"}"#).unwrap(); + crate::domain::registry::set_project_pool(&p, "fremd", Some("../../../../tmp/x")).unwrap(); - let cfg = read_project_config_in(&p, "fremd").unwrap(); - assert_eq!(cfg.pool.as_deref(), Some("../../../../tmp/x")); - // Gelesen wird der Wert noch, aber er darf keinen Pfad bilden. - assert!(check_name(cfg.pool.as_deref().unwrap()).is_err()); + let err = project_pool_dir_in(&p, "fremd").unwrap_err(); + assert!(err.contains("ungültiger Pool")); } /// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur @@ -586,7 +718,7 @@ mod tests { fn projekt_wizard_scaffold() { let p = tmp_paths(); let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project_full_in( + let id = create_project_full_in( &p, "neu", None, @@ -610,10 +742,17 @@ mod tests { ); 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.as_str())); + // ID ist UUID und Registry-Schlüssel; Pool liegt in der Registry. + assert!(uuid::Uuid::parse_str(&id).is_ok()); + assert_eq!(project_dir(&p, &id).unwrap(), dir); + assert_eq!(project_pool(&p, &id).unwrap().as_deref(), Some(kunde.as_str())); + let cfg = read_project_config_in(&p, &id).unwrap(); + assert_eq!(cfg.id.as_deref(), Some(id.as_str())); + assert_eq!(cfg.name.as_deref(), Some("neu")); assert_eq!(cfg.terminal.title.as_deref(), Some("Neu")); assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); + // Registry-Eintrag und Ordner passen zusammen. + assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir); let settings: serde_json::Value = serde_json::from_str( &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), @@ -639,11 +778,19 @@ mod tests { #[test] fn projekt_wizard_minimal_ohne_pool_und_workdir() { let p = tmp_paths(); - create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap(); + let id = + 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!(!project_config_path(&p, "neu").unwrap().is_file()); + // config.json entsteht immer — sie trägt die Projekt-Identität. + let v: serde_json::Value = serde_json::from_str( + &fs::read_to_string(project_config_path(&p, &id).unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(v["id"], id.as_str()); + assert_eq!(v["name"], "neu"); + assert!(v.get("terminal").is_none()); let settings: serde_json::Value = serde_json::from_str( &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), ) @@ -653,7 +800,7 @@ mod tests { } #[test] - fn projekt_wizard_doppelt_scheitert() { + fn projekt_wizard_doppelter_name_scheitert() { let p = tmp_paths(); create_project(&p, "neu").unwrap(); let err = @@ -690,10 +837,10 @@ mod tests { create_project(&p, "proj").unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap(); - let cfg: ProjectConfig = - 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())); + assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(kunde.as_str())); + // die Zuordnung ist maschinenlokal — die Projekt-Config bleibt ohne pool-Key + let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap(); + assert!(!raw.contains("pool")); let pools = crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap(); @@ -714,7 +861,7 @@ mod tests { create_project(&p, "proj").unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap(); unassign_pool_in(&p, "proj").unwrap(); - assert!(!project_config_path(&p, "proj").unwrap().exists()); + assert_eq!(project_pool(&p, "proj").unwrap(), None); } #[test] @@ -736,12 +883,154 @@ mod tests { assign_pool_in(&p, "proj", &privat).unwrap(); assign_pool_in(&p, "proj", &kunde).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 })); + assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(kunde.as_str())); } - // -- Projekt anlegen / löschen -- + // -- Projekt-Config: Identität, Round-Trip, Icon -- + + #[test] + fn verify_erkennt_fremden_ordner() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + assert!(verify_project_dir_in(&p, "proj").is_ok()); + // Ordner „ersetzt": config.json trägt eine andere ID. + fs::write( + p.projects_dir().join("proj").join(PROJECT_CONFIG_DIR).join(PROJECT_FILE), + r#"{"id": "anderes-projekt", "name": "proj"}"#, + ) + .unwrap(); + let err = verify_project_dir_in(&p, "proj").unwrap_err(); + assert!(err.contains("passt nicht zur Registry")); + } + + /// Der Config-Round-Trip (lesen, Feld ändern, neu schreiben) reicht Keys + /// durch, die diese Build-Version nicht kennt. + #[test] + fn config_roundtrip_erhaelt_fremde_keys() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + let cfg_dir = p.projects_dir().join("proj").join(PROJECT_CONFIG_DIR); + fs::write( + cfg_dir.join(PROJECT_FILE), + r#"{"id": "proj", "name": "proj", "archiveHome": "~/archiv", "kuenftig": {"x": 1}}"#, + ) + .unwrap(); + + set_terminal_config_in( + &p, + "proj", + TerminalConfig { theme: Some("dracula".into()), ..Default::default() }, + ) + .unwrap(); + + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(cfg_dir.join(PROJECT_FILE)).unwrap()).unwrap(); + assert_eq!(v["kuenftig"]["x"], 1); + assert_eq!(v["archiveHome"], "~/archiv"); + assert_eq!(v["terminal"]["theme"], "dracula"); + assert_eq!(v["id"], "proj"); + } + + #[test] + fn icon_wandert_in_den_projekt_config_ordner() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + let src = p.home.join("bild.PNG"); + fs::write(&src, b"png").unwrap(); + + set_terminal_config_in( + &p, + "proj", + TerminalConfig { icon: Some(src.display().to_string()), ..Default::default() }, + ) + .unwrap(); + + let cfg = read_project_config_in(&p, "proj").unwrap(); + assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png")); + let dest = p.projects_dir().join("proj").join(PROJECT_CONFIG_DIR).join("icon.png"); + assert!(dest.is_file()); + assert_eq!(resolve_icon_path(&p, "proj", "icon.png").unwrap(), dest); + } + + // -- Migration Alt-Layout -- + + /// Projekt im Alt-Layout: Registry-Schlüssel = Name, ai-control.json im + /// Projekt-Root, Icon im zentralen Icons-Verzeichnis, keine .ai-control/. + fn create_legacy_project(p: &Paths, name: &str) -> PathBuf { + let dir = p.projects_dir().join(name); + fs::create_dir_all(dir.join(".claude")).unwrap(); + register_project(p, name, &dir).unwrap(); + dir + } + + #[test] + fn migration_altes_layout() { + let p = tmp_paths(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + let dir = create_legacy_project(&p, "proj"); + fs::write( + dir.join(LEGACY_PROJECT_FILE), + format!( + r#"{{"pool": "{kunde}", "terminal": {{"icon": "proj.png"}}, "archiveHome": "~/archiv"}}"# + ), + ) + .unwrap(); + fs::create_dir_all(p.icons_dir()).unwrap(); + fs::write(p.icons_dir().join("proj.png"), b"png").unwrap(); + + migrate_layout_in(&p).unwrap(); + + // Registry-Schlüssel ist jetzt die UUID aus der config.json. + let reg = load_registry(&p).unwrap(); + assert_eq!(reg.len(), 1); + let id = reg.keys().next().unwrap().clone(); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + assert_eq!(project_pool(&p, &id).unwrap().as_deref(), Some(kunde.as_str())); + assert!(!dir.join(LEGACY_PROJECT_FILE).exists()); + + let cfg = read_project_config_in(&p, &id).unwrap(); + assert_eq!(cfg.id.as_deref(), Some(id.as_str())); + assert_eq!(cfg.name.as_deref(), Some("proj")); + assert_eq!(cfg.archive_home.as_deref(), Some("~/archiv")); + // Icon liegt jetzt im Projekt, das zentrale Verzeichnis ist geräumt. + assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png")); + assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file()); + assert!(!p.icons_dir().join("proj.png").exists()); + assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir); + + // Zweiter Lauf ändert nichts mehr. + migrate_layout_in(&p).unwrap(); + let reg2 = load_registry(&p).unwrap(); + assert_eq!(reg2.keys().collect::>(), vec![&id]); + } + + #[test] + fn migration_vergibt_id_auch_ohne_altdatei() { + let p = tmp_paths(); + let dir = create_legacy_project(&p, "proj"); + migrate_layout_in(&p).unwrap(); + let reg = load_registry(&p).unwrap(); + let id = reg.keys().next().unwrap().clone(); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + let cfg = read_project_config_in(&p, &id).unwrap(); + assert_eq!(cfg.name.as_deref(), Some("proj")); + assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir); + } + + #[test] + fn migration_migriertes_projekt_bleibt_unveraendert() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + let before = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap(); + migrate_layout_in(&p).unwrap(); + assert_eq!( + fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap(), + before + ); + assert!(load_registry(&p).unwrap().contains_key("proj")); + } + + // -- Projekt anlegen / importieren / löschen -- #[test] fn projekt_anlegen() { @@ -751,10 +1040,62 @@ mod tests { let projects = list_projects_in(&p).unwrap(); assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, "proj"); assert_eq!(projects[0].name, "proj"); assert_eq!(projects[0].pool, None); } + /// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen + /// (Sync-Fall), Arbeitsordner und Archiv-Home werden angelegt, die + /// Archiv-Permission nachgezogen. + #[test] + fn projekt_import_uebernimmt_id_und_legt_pfade_an() { + let p = tmp_paths(); + let dir = p.home.join("import").join("proj"); + fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap(); + fs::write( + dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE), + r#"{"id": "sync-id", "name": "Mein Projekt", "archiveHome": "~/archiv/proj"}"#, + ) + .unwrap(); + fs::create_dir_all(dir.join(".claude")).unwrap(); + fs::write( + settings_path(&dir), + r#"{"permissions": {"additionalDirectories": ["~/work/proj"]}}"#, + ) + .unwrap(); + + add_project_in(&p, &dir.display().to_string()).unwrap(); + + assert_eq!(project_dir(&p, "sync-id").unwrap(), dir); + assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt"); + assert!(p.home.join("work/proj").is_dir()); + assert!(p.home.join("archiv/proj").is_dir()); + let raw = fs::read_to_string(settings_path(&dir)).unwrap(); + assert!(raw.contains("~/archiv/proj")); + assert!(raw.contains("Edit(~/archiv/proj/**)")); + + // Doppelt aufnehmen scheitert an der ID. + let err = add_project_in(&p, &dir.display().to_string()).unwrap_err(); + assert!(err.contains("schon registriert")); + } + + /// Import ohne mitgebrachte Config: neue UUID, Ordnername wird Anzeigename. + #[test] + fn projekt_import_ohne_config_erzeugt_id() { + let p = tmp_paths(); + let dir = p.home.join("import").join("roh"); + fs::create_dir_all(&dir).unwrap(); + + add_project_in(&p, &dir.display().to_string()).unwrap(); + + let reg = load_registry(&p).unwrap(); + let id = reg.keys().next().unwrap().clone(); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + assert_eq!(display_name_in(&p, &id).unwrap(), "roh"); + assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir); + } + #[test] fn projekt_doppelt_anlegen_scheitert() { let p = tmp_paths(); @@ -773,9 +1114,9 @@ mod tests { #[test] fn projekt_loeschen_laesst_arbeitsordner() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); - delete_project_in(&p, "proj", false).unwrap(); + delete_project_in(&p, &id, false).unwrap(); assert!(!p.projects_dir().join("proj").exists()); assert!(p.home.join("projects").join("proj").is_dir()); } @@ -783,9 +1124,9 @@ mod tests { #[test] fn projekt_loeschen_mit_arbeitsordner() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); - delete_project_in(&p, "proj", true).unwrap(); + delete_project_in(&p, &id, true).unwrap(); assert!(!p.projects_dir().join("proj").exists()); assert!(!p.home.join("projects").join("proj").exists()); } @@ -793,39 +1134,46 @@ mod tests { #[test] fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + let id = 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()); + assert!(delete_project_in(&p, &id, true).is_err()); } #[test] fn projekt_arbeitsordner_auslesen() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + let id = 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"]); + assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]); // Projekt ohne settings.json → leer - create_project(&p, "alt").unwrap(); - assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty()); + let dir = p.home.join("ohne-settings"); + fs::create_dir_all(&dir).unwrap(); + add_project_in(&p, &dir.display().to_string()).unwrap(); + let alt = list_projects_in(&p) + .unwrap() + .into_iter() + .find(|pr| pr.name == "ohne-settings") + .unwrap(); + assert!(project_work_dirs_in(&p, &alt.id).unwrap().is_empty()); } #[test] fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) + let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) .unwrap(); fs::create_dir_all(p.home.join("projects/extra")).unwrap(); let picked = p.home.join("projects/extra").to_string_lossy().into_owned(); - add_work_dir_in(&p, "proj", &picked).unwrap(); + add_work_dir_in(&p, &id, &picked).unwrap(); // gespeichert wird ~-kontrahiert, in beiden permissions-Feldern - assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]); + assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/extra"]); let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); assert!(raw.contains("Edit(~/projects/extra/**)")); - assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt + assert!(add_work_dir_in(&p, &id, &picked).is_err()); // doppelt - remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap(); - assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty()); + remove_work_dir_in(&p, &id, "~/projects/extra").unwrap(); + assert!(project_work_dirs_in(&p, &id).unwrap().is_empty()); let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); assert!(!raw.contains("~/projects/extra")); // Ordner selbst bleibt @@ -835,15 +1183,17 @@ mod tests { #[test] fn projektordner_verlegen() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) + let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) .unwrap(); // Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu. let new_dir = p.home.join("elsewhere").join("proj"); fs::create_dir_all(p.home.join("elsewhere")).unwrap(); fs::rename(p.projects_dir().join("proj"), &new_dir).unwrap(); - set_project_dir_in(&p, "proj", &new_dir.to_string_lossy()).unwrap(); + set_project_dir_in(&p, &id, &new_dir.to_string_lossy()).unwrap(); - assert_eq!(project_dir(&p, "proj").unwrap(), new_dir); + assert_eq!(project_dir(&p, &id).unwrap(), new_dir); + // Die config.json zieht mit — Verifikation greift am neuen Ort. + assert_eq!(verify_project_dir_in(&p, &id).unwrap(), new_dir); let raw = fs::read_to_string(settings_path(&new_dir)).unwrap(); assert!(raw.contains("~/elsewhere/proj/memory")); assert!(raw.contains("Edit(~/elsewhere/proj/**)")); diff --git a/src-tauri/src/domain/registry.rs b/src-tauri/src/domain/registry.rs index d8bfc75..6006684 100644 --- a/src-tauri/src/domain/registry.rs +++ b/src-tauri/src/domain/registry.rs @@ -1,26 +1,53 @@ -//! Projekt-Registry: projects.json (Name → Ordner) unter ~/.config/ai-control. +//! Projekt-Registry: projects.json (Projekt-ID → Ordner + Pool) unter +//! ~/.config/ai-control. Schlüssel ist die Projekt-UUID aus der +//! .ai-control/config.json des Projekts; die Pool-Zuordnung ist +//! maschinenlokal und lebt deshalb hier, nicht in der syncbaren +//! Projekt-Config. +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use crate::domain::paths::{contract_home, expand_home, Paths}; -/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte. -pub(crate) fn load_registry(paths: &Paths) -> Result, String> { +/// Registry-Eintrag eines Projekts: Ordner + Pool-Zuordnung dieser Maschine. +pub(crate) struct RegEntry { + pub(crate) dir: PathBuf, + pub(crate) pool: Option, +} + +/// Dateiformat: Alt-Einträge sind reine Pfad-Strings, neue Einträge Objekte +/// mit path + pool. Geschrieben wird der String, solange kein Pool gesetzt ist. +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum RawEntry { + Path(String), + Full { + path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pool: Option, + }, +} + +/// Registry Projekt-ID → Eintrag; ohne projects.json gibt es keine Projekte. +pub(crate) fn load_registry(paths: &Paths) -> Result, String> { let file = paths.projects_file(); if !file.is_file() { return Ok(BTreeMap::new()); } let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; - let map: BTreeMap = + let map: BTreeMap = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?; Ok( map .into_iter() - .map(|(name, p)| { - let dir = expand_home(paths, &p); - (name, dir) + .map(|(name, e)| { + let (path, pool) = match e { + RawEntry::Path(p) => (p, None), + RawEntry::Full { path, pool } => (path, pool), + }; + (name, RegEntry { dir: expand_home(paths, &path), pool }) }) .collect(), ) @@ -28,11 +55,20 @@ pub(crate) fn load_registry(paths: &Paths) -> Result, pub(crate) fn save_registry( paths: &Paths, - reg: &BTreeMap, + reg: &BTreeMap, ) -> Result<(), String> { - let contracted: 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())?; + let raw_map: BTreeMap<&String, RawEntry> = reg + .iter() + .map(|(name, e)| { + let path = contract_home(paths, &e.dir); + let raw = match &e.pool { + None => RawEntry::Path(path), + Some(pool) => RawEntry::Full { path, pool: Some(pool.clone()) }, + }; + (name, raw) + }) + .collect(); + let raw = serde_json::to_string_pretty(&raw_map).map_err(|e| e.to_string())?; fs::create_dir_all(paths.config_dir()) .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; let file = paths.projects_file(); @@ -41,9 +77,36 @@ pub(crate) fn save_registry( /// Ordner eines registrierten Projekts. pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result { - load_registry(paths)? - .remove(name) - .ok_or_else(|| format!("Projekt nicht registriert: {name}")) + Ok( + load_registry(paths)? + .remove(name) + .ok_or_else(|| format!("Projekt nicht registriert: {name}"))? + .dir, + ) +} + +/// Pool-Zuordnung eines registrierten Projekts (maschinenlokal). +pub(crate) fn project_pool(paths: &Paths, name: &str) -> Result, String> { + Ok( + load_registry(paths)? + .remove(name) + .ok_or_else(|| format!("Projekt nicht registriert: {name}"))? + .pool, + ) +} + +/// Setzt bzw. löscht die Pool-Zuordnung eines Projekts in der Registry. +pub(crate) fn set_project_pool( + paths: &Paths, + name: &str, + pool: Option<&str>, +) -> Result<(), String> { + let mut reg = load_registry(paths)?; + let entry = reg + .get_mut(name) + .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?; + entry.pool = pool.map(str::to_string); + save_registry(paths, ®) } /// Nimmt ein Projekt in die Registry auf. @@ -56,7 +119,7 @@ pub(crate) fn register_project( if reg.contains_key(name) { return Err(format!("Projekt existiert bereits: {name}")); } - reg.insert(name.to_string(), dir.to_path_buf()); + reg.insert(name.to_string(), RegEntry { dir: dir.to_path_buf(), pool: None }); save_registry(paths, ®) } diff --git a/src-tauri/src/domain/testutil.rs b/src-tauri/src/domain/testutil.rs index 61a7be6..7aa3de8 100644 --- a/src-tauri/src/domain/testutil.rs +++ b/src-tauri/src/domain/testutil.rs @@ -75,7 +75,9 @@ pub(crate) fn make_apikey_pool( create_apikey_pool_in(p, store, name, key, true).unwrap() } -/// Minimal-Projekt: Ordner mit .claude/ + Registry-Eintrag. +/// Minimal-Projekt: Ordner mit .claude/, .ai-control/config.json und +/// Registry-Eintrag. Die Projekt-ID ist der Name — Tests adressieren das +/// Projekt damit direkt, ohne UUID-Variablen durchzureichen. pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> { crate::domain::check_name(name)?; let dir = paths.projects_dir().join(name); @@ -84,6 +86,13 @@ pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> { } fs::create_dir_all(dir.join(".claude")) .map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?; + fs::create_dir_all(dir.join(".ai-control")) + .map_err(|e| format!("{}: {e}", dir.join(".ai-control").display()))?; + fs::write( + dir.join(".ai-control").join("config.json"), + format!("{{\"id\": \"{name}\", \"name\": \"{name}\"}}\n"), + ) + .map_err(|e| e.to_string())?; register_project(paths, name, &dir) } diff --git a/src-tauri/src/domain/todo.rs b/src-tauri/src/domain/todo.rs index e3e4b83..3ea4a05 100644 --- a/src-tauri/src/domain/todo.rs +++ b/src-tauri/src/domain/todo.rs @@ -129,17 +129,18 @@ mod tests { #[test] fn todo_zuschalten_und_abschalten() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) - .unwrap(); - assert!(!todo_state_in(&p, "proj").unwrap()); + let id = + create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) + .unwrap(); + assert!(!todo_state_in(&p, &id).unwrap()); - set_todo_in(&p, "proj", true).unwrap(); - assert!(todo_state_in(&p, "proj").unwrap()); + set_todo_in(&p, &id, true).unwrap(); + assert!(todo_state_in(&p, &id).unwrap()); let todo_path = p.projects_dir().join("proj").join(TODO_FILE); assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON); // doppelt aktivieren erzeugt keinen zweiten Hook - set_todo_in(&p, "proj", true).unwrap(); + set_todo_in(&p, &id, true).unwrap(); let settings: serde_json::Value = serde_json::from_str( &fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(), ) @@ -149,8 +150,8 @@ mod tests { // Abschalten: Hook weg, Datei (mit Inhalt) bleibt fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap(); - set_todo_in(&p, "proj", false).unwrap(); - assert!(!todo_state_in(&p, "proj").unwrap()); + set_todo_in(&p, &id, false).unwrap(); + assert!(!todo_state_in(&p, &id).unwrap()); assert!(todo_path.is_file()); assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig")); } diff --git a/src-tauri/src/domain/usage.rs b/src-tauri/src/domain/usage.rs index 5364259..1d337f2 100644 --- a/src-tauri/src/domain/usage.rs +++ b/src-tauri/src/domain/usage.rs @@ -138,8 +138,9 @@ pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result, // kodierter Projektpfad → Projektname let mut names = HashMap::new(); - for (name, dir) in load_registry(paths)? { - names.insert(encode_project_path(&dir.to_string_lossy()), name); + for (id, entry) in load_registry(paths)? { + let name = crate::domain::project::display_name_in(paths, &id)?; + names.insert(encode_project_path(&entry.dir.to_string_lossy()), name); } let mut seen = HashSet::new(); diff --git a/src-tauri/src/domain/watcher.rs b/src-tauri/src/domain/watcher.rs index cd2469b..380363c 100644 --- a/src-tauri/src/domain/watcher.rs +++ b/src-tauri/src/domain/watcher.rs @@ -5,7 +5,7 @@ use std::process::Command; use std::time::Duration; use crate::domain::paths::Paths; -use crate::domain::project::list_projects_in; +use crate::domain::project::{display_name_in, list_projects_in}; use crate::domain::registry::project_dir; use crate::domain::settings::sync_on_session_end; @@ -20,11 +20,11 @@ fn git_root(dir: &std::path::Path) -> Option { .map(|a| a.to_path_buf()) } -/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die +/// (Projekt-ID, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die /// Projektliste; Grundlage für Session-Watcher und Tray-Menü. fn project_state(paths: &Paths) -> Vec<(String, bool)> { let mut state: Vec<(String, bool)> = list_projects_in(paths) - .map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect()) + .map(|ps| ps.into_iter().map(|p| (p.id, p.running)).collect()) .unwrap_or_default(); state.sort(); state @@ -98,7 +98,9 @@ pub(crate) fn spawn_session_watcher() { for project in ended { if let Ok(dir) = project_dir(&paths, project) { if let Some(repo) = git_root(&dir) { - sync_session_context(&repo, project); + let name = + display_name_in(&paths, project).unwrap_or_else(|_| project.clone()); + sync_session_context(&repo, &name); } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3498572..91b8b3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -72,11 +72,13 @@ pub fn run() { // Eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag // (muss vor dem GTK-Init stehen; No-op außerhalb Linux). platform::set_app_id(&format!("aicontrol-{project}")); - let icon = domain::project::terminal_config(&project) + let icon = domain::project::project_config(&project) .expect("Projekt-Config nicht lesbar") + .terminal .icon .map(|i| { - domain::project::resolve_icon_path(&domain::paths::Paths::real(), &i) + domain::project::resolve_icon_path(&domain::paths::Paths::real(), &project, &i) + .expect("Projekt nicht registriert") .to_string_lossy() .into_owned() }); diff --git a/src-tauri/src/platform/linux/desktop.rs b/src-tauri/src/platform/linux/desktop.rs index 341ad4c..316568d 100644 --- a/src-tauri/src/platform/linux/desktop.rs +++ b/src-tauri/src/platform/linux/desktop.rs @@ -1,11 +1,12 @@ //! Pro-Projekt-.desktop-Dateien: dash-to-dock/GNOME ordnen dem Fenster -//! (app_id `aicontrol-`, via set_prgname) darüber das Projekt-Icon zu. +//! (app_id `aicontrol-`, via set_prgname) darüber Icon und +//! Anzeigename des Projekts zu. use std::fs; use std::path::PathBuf; use crate::domain::paths::Paths; -use crate::domain::project::{read_project_config_in, resolve_icon_path, TerminalConfig}; +use crate::domain::project::{read_project_config_in, resolve_icon_path, ProjectConfig}; use crate::domain::registry::load_registry; /// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien. @@ -16,7 +17,7 @@ fn applications_dir(paths: &Paths) -> PathBuf { /// Schreibt/aktualisiert die .desktop eines Projekts. NoDisplay=true hält den /// App-Starter sauber. -pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) { +pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &ProjectConfig) { let dir = applications_dir(paths); if fs::create_dir_all(&dir).is_err() { return; @@ -26,15 +27,17 @@ pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &Termina .and_then(|p| p.to_str().map(str::to_string)) .unwrap_or_else(|| "ai-control".into()); let icon_line = cfg + .terminal .icon .as_deref() - .map(|i| resolve_icon_path(paths, i)) + .and_then(|i| resolve_icon_path(paths, project, i).ok()) .filter(|p| p.exists()) .and_then(|p| p.to_str().map(str::to_string)) .map(|p| format!("Icon={p}\n")) .unwrap_or_default(); + let name = cfg.name.as_deref().unwrap_or(project); let content = format!( - "[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\ + "[Desktop Entry]\nType=Application\nName={name}\nExec={exec} --terminal {project}\n\ {icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n" ); let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content); @@ -53,9 +56,7 @@ pub(crate) fn sync_all_desktops(paths: &Paths) { return; }; for project in reg.keys() { - let cfg = read_project_config_in(paths, project) - .map(|c| c.terminal) - .unwrap_or_default(); + let cfg = read_project_config_in(paths, project).unwrap_or_default(); write_terminal_desktop(paths, project, &cfg); } let dir = applications_dir(paths); diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index 7488e73..07e3d79 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -7,7 +7,7 @@ use std::process::Command; use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE}; use crate::domain::paths::Paths; use crate::domain::pool::APIKEY_FILE; -use crate::domain::project::TerminalConfig; +use crate::domain::project::ProjectConfig; use crate::platform::{Anchor, TrayCallbacks}; // ---------- Aktivierung / Fokus / Icons ---------- @@ -83,7 +83,7 @@ pub(crate) fn reveal_path(path: &Path) { // ---------- Desktop-Integration (nur Linux substantiell) ---------- -pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &TerminalConfig) {} +pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &ProjectConfig) {} pub(crate) fn remove_terminal_desktop(_paths: &Paths, _project: &str) {} pub(crate) fn sync_all_desktops(_paths: &Paths) {} diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs index 7cb289c..9d7bf78 100644 --- a/src-tauri/src/platform/windows.rs +++ b/src-tauri/src/platform/windows.rs @@ -9,7 +9,7 @@ use std::process::Command; use crate::domain::paths::Paths; use crate::domain::pool::APIKEY_FILE; -use crate::domain::project::TerminalConfig; +use crate::domain::project::ProjectConfig; use crate::platform::{Anchor, TrayCallbacks}; // ---------- Prozesse ---------- @@ -77,7 +77,7 @@ pub(crate) fn symlink(_target: &Path, _link: &Path) -> Result<(), String> { // ---------- Desktop-Integration (nur Linux substantiell) ---------- -pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &TerminalConfig) {} +pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &ProjectConfig) {} pub(crate) fn remove_terminal_desktop(_paths: &Paths, _project: &str) {} pub(crate) fn sync_all_desktops(_paths: &Paths) {} diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 9f48d20..c1e198e 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -6,7 +6,9 @@ use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths}; -use crate::domain::project::{project_pool_dir, terminal_config, TerminalConfig}; +use crate::domain::project::{ + project_config, project_pool_dir, verify_project_dir_in, ProjectConfig, +}; use crate::domain::registry::project_dir; use crate::domain::settings::claude_command; @@ -27,7 +29,10 @@ pub struct Terminals(pub Mutex>); /// das Terminal-Fenster hinter der aktiven App. #[tauri::command] pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> { - let dir = project_dir(&Paths::real(), &project)?; + // Prüft zugleich die Projekt-Identität: die config.json im registrierten + // Ordner muss diese Projekt-ID tragen (verschobene/ersetzte Ordner fallen + // hier auf, statt eine fremde Session zu starten). + let dir = verify_project_dir_in(&Paths::real(), &project)?; if !dir.is_dir() { return Err(format!("Projektordner fehlt: {}", dir.display())); } @@ -61,10 +66,15 @@ fn theme_background(theme: &str) -> (u8, u8, u8) { pub fn build_window( app: &AppHandle, project: &str, - cfg: &TerminalConfig, + cfg: &ProjectConfig, ) -> tauri::Result<()> { - let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); - let title = cfg.title.as_deref().unwrap_or(project); + let (r, g, b) = theme_background(cfg.terminal.theme.as_deref().unwrap_or_default()); + let title = cfg + .terminal + .title + .as_deref() + .or(cfg.name.as_deref()) + .unwrap_or(project); let builder = WebviewWindowBuilder::new( app, format!("term-{project}"), @@ -369,9 +379,14 @@ pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), St let _ = w.set_focus(); return Ok(()); } - let cfg = terminal_config(&project)?; - let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); - let title = cfg.title.as_deref().unwrap_or(project.as_str()); + let cfg = project_config(&project)?; + let (r, g, b) = theme_background(cfg.terminal.theme.as_deref().unwrap_or_default()); + let title = cfg + .terminal + .title + .as_deref() + .or(cfg.name.as_deref()) + .unwrap_or(project.as_str()); let builder = WebviewWindowBuilder::new( &app, &label, diff --git a/src/components/Popup.vue b/src/components/Popup.vue index bfad3ce..ab7537b 100644 --- a/src/components/Popup.vue +++ b/src/components/Popup.vue @@ -6,6 +6,7 @@ import { currentMonitor } from "@tauri-apps/api/window"; import { LogicalSize } from "@tauri-apps/api/dpi"; interface Project { + id: string; name: string; running: boolean; terminal: { theme: string | null; icon: string | null; title: string | null }; @@ -25,9 +26,9 @@ async function refresh() { try { projects.value = await invoke("list_projects"); for (const p of projects.value) { - if (p.terminal.icon && !(p.name in icons.value)) { - icons.value[p.name] = - (await invoke("project_icon", { project: p.name })) ?? ""; + if (p.terminal.icon && !(p.id in icons.value)) { + icons.value[p.id] = + (await invoke("project_icon", { project: p.id })) ?? ""; } } } catch (e) { @@ -36,7 +37,7 @@ async function refresh() { } async function pick(p: Project) { - await invoke("start_or_focus_cmd", { project: p.name }); + await invoke("start_or_focus_cmd", { project: p.id }); await win.hide(); } async function openMain() { @@ -72,9 +73,9 @@ onUnmounted(() => {