Projekt-Identität und -Config neu: .ai-control/config.json mit UUID und Name

- 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).
This commit is contained in:
marcus hinz
2026-07-20 10:55:21 +02:00
parent 6d44f77671
commit b9d006184a
20 changed files with 830 additions and 346 deletions
+8 -4
View File
@@ -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/<pool>/ one Claude config directory per pool
└── pool.json name + credential type
<project directory>/ 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
+73 -69
View File
@@ -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<tauri::Wry> {
#[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<tauri::Wry> {
}
}
})
.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<tauri::Wry>) -> 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<tauri::Wry> {
.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<tauri::Wry> {
}
}
})
.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())
}
+26 -16
View File
@@ -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<String, String> {
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<Option<String>, 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<Vec<String>, 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));
}
+12 -5
View File
@@ -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<PathBuf> {
let paths = Paths::real();
@@ -27,7 +27,7 @@ pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
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)
}
+2 -2
View File
@@ -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")
}
+14 -6
View File
@@ -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"));
}
+508 -158
View File
@@ -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<String>,
@@ -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<String>,
pub(crate) id: Option<String>,
/// Anzeigename (Projektliste, Fenstertitel-Fallback, .desktop-Name).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) name: Option<String>,
#[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<PathBuf, String> {
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<Vec<String>, 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<Vec<Project>, 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<String, String> {
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<PathBuf, String> {
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<Option<PathBuf>, 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<Option<PathBuf>, 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<TerminalConfig, String> {
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<ProjectConfig, String> {
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/<name>.
/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
/// ~/claude-projects/<name>. 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<String, String> {
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, &reg)?;
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, &reg)?;
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<PathBuf, String> {
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<String, RegEntry> = 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/<name> 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<_>>(), 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/**)"));
+78 -15
View File
@@ -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<BTreeMap<String, PathBuf>, String> {
/// Registry-Eintrag eines Projekts: Ordner + Pool-Zuordnung dieser Maschine.
pub(crate) struct RegEntry {
pub(crate) dir: PathBuf,
pub(crate) pool: Option<String>,
}
/// 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<String>,
},
}
/// Registry Projekt-ID → Eintrag; ohne projects.json gibt es keine Projekte.
pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, RegEntry>, 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<String, String> =
let map: BTreeMap<String, RawEntry> =
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<BTreeMap<String, PathBuf>,
pub(crate) fn save_registry(
paths: &Paths,
reg: &BTreeMap<String, PathBuf>,
reg: &BTreeMap<String, RegEntry>,
) -> 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<PathBuf, String> {
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<Option<String>, 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, &reg)
}
/// 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, &reg)
}
+10 -1
View File
@@ -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)
}
+9 -8
View File
@@ -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"));
}
+3 -2
View File
@@ -138,8 +138,9 @@ pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>,
// 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();
+6 -4
View File
@@ -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<std::path::PathBuf> {
.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);
}
}
}
+4 -2
View File
@@ -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()
});
+9 -8
View File
@@ -1,11 +1,12 @@
//! Pro-Projekt-.desktop-Dateien: dash-to-dock/GNOME ordnen dem Fenster
//! (app_id `aicontrol-<projekt>`, via set_prgname) darüber das Projekt-Icon zu.
//! (app_id `aicontrol-<projekt-id>`, 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);
+2 -2
View File
@@ -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) {}
+2 -2
View File
@@ -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) {}
+23 -8
View File
@@ -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<HashMap<String, Session>>);
/// 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,
+7 -6
View File
@@ -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<Project[]>("list_projects");
for (const p of projects.value) {
if (p.terminal.icon && !(p.name in icons.value)) {
icons.value[p.name] =
(await invoke<string | null>("project_icon", { project: p.name })) ?? "";
if (p.terminal.icon && !(p.id in icons.value)) {
icons.value[p.id] =
(await invoke<string | null>("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(() => {
<template>
<div ref="wrapRef" class="wrap" :style="{ maxHeight: maxH + 'px' }">
<ul class="list">
<li v-for="p in projects" :key="p.name" class="row" @click="pick(p)">
<li v-for="p in projects" :key="p.id" class="row" @click="pick(p)">
<span class="dot" :class="{ on: p.running }"></span>
<img v-if="icons[p.name]" class="ic" :src="icons[p.name]" />
<img v-if="icons[p.id]" class="ic" :src="icons[p.id]" />
<span v-else class="ic ph"></span>
<span class="nm">{{ p.name }}</span>
</li>
+31 -26
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
interface Project {
id: string;
name: string;
path: string;
pool: string | null;
@@ -44,7 +45,7 @@ const error = ref("");
const icons = ref<Record<string, string>>({});
function iconKey(p: Project): string | null {
return p.terminal.icon ? `${p.name}:${p.terminal.icon}` : null;
return p.terminal.icon ? `${p.id}:${p.terminal.icon}` : null;
}
function projIcon(p: Project): string | undefined {
@@ -58,7 +59,7 @@ async function loadIcons() {
if (!key || key in icons.value) continue;
try {
const data = await invoke<string | null>("project_icon", {
project: p.name,
project: p.id,
});
icons.value[key] = data ?? "";
} catch (e) {
@@ -81,7 +82,7 @@ async function refresh() {
async function stop(project: Project) {
try {
await invoke("stop_project", { project: project.name });
await invoke("stop_project", { project: project.id });
await refresh();
} catch (e) {
error.value = String(e);
@@ -90,7 +91,7 @@ async function stop(project: Project) {
async function openTerminal(project: Project) {
try {
await invoke("open_terminal", { project: project.name });
await invoke("open_terminal", { project: project.id });
await refresh();
} catch (e) {
error.value = String(e);
@@ -98,6 +99,7 @@ async function openTerminal(project: Project) {
}
interface PendingRestart {
id: string;
name: string;
from: string | null;
to: string;
@@ -110,9 +112,9 @@ async function assign(project: Project, event: Event) {
const pool = (event.target as HTMLSelectElement).value;
const from = project.pool;
try {
await invoke("assign_pool", { project: project.name, pool });
await invoke("assign_pool", { project: project.id, pool });
if (project.running && pool !== from) {
pending.value = { name: project.name, from, to: pool };
pending.value = { id: project.id, name: project.name, from, to: pool };
}
await refresh();
} catch (e) {
@@ -124,7 +126,7 @@ async function restartNow() {
const p = pending.value!;
restarting.value = true;
try {
await invoke("restart_project", { project: p.name });
await invoke("restart_project", { project: p.id });
pending.value = null;
await refresh();
} catch (e) {
@@ -151,6 +153,7 @@ const THEME_NAMES: [string, string][] = [
];
interface TerminalSettings {
id: string;
name: string;
path: string;
theme: string;
@@ -165,14 +168,15 @@ const settings = ref<TerminalSettings | null>(null);
async function openSettings(p: Project) {
try {
const todo = await invoke<boolean>("todo_state", { project: p.name });
const todo = await invoke<boolean>("todo_state", { project: p.id });
const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.name,
project: p.id,
});
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: p.name,
project: p.id,
});
settings.value = {
id: p.id,
name: p.name,
path: p.path,
theme: p.terminal.theme ?? "mocha",
@@ -194,9 +198,9 @@ async function changeProjectDir() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("set_project_dir", { project: s.name, dir });
await invoke("set_project_dir", { project: s.id, dir });
await refresh();
const p = projects.value.find((x) => x.name === s.name);
const p = projects.value.find((x) => x.id === s.id);
if (p) s.path = p.path;
} catch (e) {
error.value = String(e);
@@ -210,8 +214,8 @@ async function addWorkDir() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("add_work_dir", { project: s.name, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.name });
await invoke("add_work_dir", { project: s.id, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.id });
} catch (e) {
error.value = String(e);
}
@@ -220,8 +224,8 @@ async function addWorkDir() {
async function removeWorkDir(dir: string) {
const s = settings.value!;
try {
await invoke("remove_work_dir", { project: s.name, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.name });
await invoke("remove_work_dir", { project: s.id, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.id });
} catch (e) {
error.value = String(e);
}
@@ -235,9 +239,9 @@ async function chooseArchive() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("set_archive_home_cmd", { project: s.name, dir });
await invoke("set_archive_home_cmd", { project: s.id, dir });
s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: s.name,
project: s.id,
});
} catch (e) {
error.value = String(e);
@@ -247,7 +251,7 @@ async function chooseArchive() {
async function clearArchive() {
const s = settings.value!;
try {
await invoke("clear_archive_home_cmd", { project: s.name });
await invoke("clear_archive_home_cmd", { project: s.id });
s.archiveHome = null;
} catch (e) {
error.value = String(e);
@@ -268,12 +272,12 @@ async function saveSettings() {
try {
const title = s.title.trim();
await invoke("set_terminal_config", {
project: s.name,
project: s.id,
theme: s.theme === "mocha" ? null : s.theme,
icon: s.icon,
title: title === "" ? null : title,
});
await invoke("set_todo", { project: s.name, enabled: s.todo });
await invoke("set_todo", { project: s.id, enabled: s.todo });
settings.value = null;
await refresh();
} catch (e) {
@@ -371,6 +375,7 @@ async function createProject() {
}
interface PendingDelete {
id: string;
name: string;
workDirs: string[];
deleteWorkDirs: boolean;
@@ -382,9 +387,9 @@ async function askDelete(p: Project) {
error.value = "";
try {
const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.name,
project: p.id,
});
pendingDelete.value = { name: p.name, workDirs, deleteWorkDirs: false };
pendingDelete.value = { id: p.id, name: p.name, workDirs, deleteWorkDirs: false };
} catch (e) {
error.value = String(e);
}
@@ -394,7 +399,7 @@ async function confirmDelete() {
const d = pendingDelete.value!;
try {
await invoke("delete_project", {
name: d.name,
project: d.id,
deleteWorkDirs: d.deleteWorkDirs,
});
pendingDelete.value = null;
@@ -409,7 +414,7 @@ async function confirmDelete() {
async function unlinkProject() {
const d = pendingDelete.value!;
try {
await invoke("remove_project", { name: d.name });
await invoke("remove_project", { project: d.id });
pendingDelete.value = null;
await refresh();
} catch (e) {
@@ -453,7 +458,7 @@ onUnmounted(() => window.clearInterval(timer));
</tr>
</thead>
<tbody>
<tr v-for="p in projects" :key="p.name">
<tr v-for="p in projects" :key="p.id">
<td class="cell-dot">
<span class="dot" :class="{ on: p.running }"></span>
</td>
+3 -2
View File
@@ -69,12 +69,13 @@ document.querySelector("header")!.addEventListener("dblclick", (e) => {
// bis die Webview lädt).
interface Project {
id: string;
name: string;
pool: string | null;
terminal: { theme: string | null; icon: string | null; title: string | null };
}
const projects = await invoke<Project[]>("list_projects");
const cfg = projects.find((p) => p.name === project);
const cfg = projects.find((p) => p.id === project);
if (cfg?.pool) {
// Anzeigename statt Pool-ID (Ordnername).
document.getElementById("pool")!.textContent = await invoke<string>(
@@ -83,7 +84,7 @@ if (cfg?.pool) {
);
}
document.getElementById("project-name")!.textContent =
cfg?.terminal.title ?? project;
cfg?.terminal.title ?? cfg?.name ?? project;
// Projekt-Icon vor den Titel — dieselbe data-URL wie in der Projektliste.
if (cfg?.terminal.icon) {