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:
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ®)?;
|
||||
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<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/**)"));
|
||||
|
||||
@@ -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, ®)
|
||||
}
|
||||
|
||||
/// 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, ®)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user