Compare commits

...

2 Commits

Author SHA1 Message Date
marcus hinz a1c95dae4a Build und Deploy getrennt: gemeinsames build.sh, Deploy-Skripte pro OS
build.sh baut nur noch (OS-Weiche für die Bundle-Liste: macOS app,dmg,
Linux deb,rpm, Git-Bash nsis); die macOS-Nachbereitung (lsregister,
.noindex) zieht nach deploy-macos.sh um. deploy-linux.sh installiert das
jüngste Bundle (dnf: rpm -Uvh --force, apt: dpkg -i),
deploy-windows.ps1 startet den NSIS-Installer.
2026-07-20 11:01:33 +02:00
marcus hinz b9d006184a 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).
2026-07-20 11:01:33 +02:00
24 changed files with 906 additions and 365 deletions
+8 -4
View File
@@ -22,7 +22,7 @@ Running Claude Code with multiple accounts or credential sets means hitting the
The app ships an MCP stdio server (`ai-control --mcp-panel`, server key `text-panel`) and provisions it into every pool, including the tool permission — no per-pool setup: 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. - **`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). 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/ ~/.config/ai-control/
├── settings.json app settings (see below) ├── 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 └── pools/<pool>/ one Claude config directory per pool
└── pool.json name + credential type └── pool.json name + credential type
<project directory>/ any path, mapped via the registry <project directory>/ any path, mapped via the registry
├── .claude/ Claude Code project configuration ├── .claude/ Claude Code project configuration
└── ai-control.json pool assignment, terminal settings, └── .ai-control/ syncs with the project
panel archive directory (archiveDir) ├── config.json project id (UUID) + display name,
│ terminal settings, panel archive
│ directory (archiveHome)
└── icon.png project icon
``` ```
### settings.json ### settings.json
+17 -16
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Gemeinsamer Release-Build für macOS/Linux (Windows: unter Git-Bash läuft
# auch dieses Script). Baut nur — ausrollen macht das jeweilige
# deploy-<os>-Script.
set -euo pipefail set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -8,22 +11,20 @@ export RUSTUP_HOME="$HOME/tools/.rustup"
export PATH="$CARGO_HOME/bin:$PATH" export PATH="$CARGO_HOME/bin:$PATH"
cd "$DIR" cd "$DIR"
case "$(uname -s)" in
Darwin)
# CI=true: DMG ohne Finder-AppleScript (Fenster-Layout) bauen — das Script # CI=true: DMG ohne Finder-AppleScript (Fenster-Layout) bauen — das Script
# wäre aus Nicht-GUI-Shells TCC-blockiert. # wäre aus Nicht-GUI-Shells TCC-blockiert.
CI=true npm run tauri build -- --bundles app,dmg "$@" CI=true npm run tauri build -- --bundles app,dmg "$@"
;;
# Dev-Bundle in ein .noindex-Verzeichnis schieben und aus Launch Services Linux)
# austragen — Spotlight indiziert .noindex-Ordner nicht, damit erscheint im npm run tauri build -- --bundles deb,rpm "$@"
# Starter/Spotlight nur die installierte Kopie unter ~/Applications. ;;
BUNDLE="$DIR/src-tauri/target/release/bundle/macos" MINGW* | MSYS*)
NOINDEX="$DIR/src-tauri/target/release/bundle/macos.noindex" npm run tauri build -- --bundles nsis "$@"
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister ;;
# lsregister -u meldet -10814, wenn das Bundle nicht registriert ist — *)
# erwartetes Ergebnis, kein Fehler. echo "unbekanntes OS: $(uname -s)" >&2
"$LSREGISTER" -u "$BUNDLE/ai-control.app" || true exit 1
rm -rf "$NOINDEX/ai-control.app" ;;
mkdir -p "$NOINDEX" esac
mv "$BUNDLE/ai-control.app" "$NOINDEX/"
"$LSREGISTER" -u "$NOINDEX/ai-control.app" || true
echo "DMG: $(ls "$DIR"/src-tauri/target/release/bundle/dmg/*.dmg)"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Linux-Deploy nach build.sh: jüngstes Paket-Bundle installieren.
# dnf-Systeme: rpm -Uvh --force (installiert auch dieselbe Version neu),
# apt-Systeme: dpkg -i. Braucht sudo.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUNDLE="$DIR/src-tauri/target/release/bundle"
if command -v dnf >/dev/null; then
PKG="$(ls -t "$BUNDLE"/rpm/ai-control-*.rpm | head -1)"
sudo rpm -Uvh --force "$PKG"
elif command -v dpkg >/dev/null; then
PKG="$(ls -t "$BUNDLE"/deb/ai-control_*.deb | head -1)"
sudo dpkg -i "$PKG"
else
echo "weder dnf noch dpkg gefunden" >&2
exit 1
fi
echo "installiert: $PKG"
echo "Haupt-App übers Tray beenden und neu starten (Migration läuft beim Start)."
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# macOS-Deploy nach build.sh: Dev-Bundle aus Launch Services austragen und in
# ein .noindex-Verzeichnis schieben — Spotlight indiziert .noindex nicht, damit
# erscheint im Starter/Spotlight nur die installierte Kopie. Installiert wird
# über das DMG (Pfad am Ende).
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUNDLE="$DIR/src-tauri/target/release/bundle/macos"
NOINDEX="$DIR/src-tauri/target/release/bundle/macos.noindex"
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
# lsregister -u meldet -10814, wenn das Bundle nicht registriert ist —
# erwartetes Ergebnis, kein Fehler.
"$LSREGISTER" -u "$BUNDLE/ai-control.app" || true
rm -rf "$NOINDEX/ai-control.app"
mkdir -p "$NOINDEX"
mv "$BUNDLE/ai-control.app" "$NOINDEX/"
"$LSREGISTER" -u "$NOINDEX/ai-control.app" || true
echo "DMG: $(ls "$DIR"/src-tauri/target/release/bundle/dmg/*.dmg)"
+13
View File
@@ -0,0 +1,13 @@
# Windows-Deploy nach dem Build (--bundles nsis): jüngsten Installer starten.
$ErrorActionPreference = "Stop"
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path
$installer = Get-ChildItem "$dir\src-tauri\target\release\bundle\nsis\ai-control_*-setup.exe" |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $installer) {
Write-Error "kein Installer gefunden — erst build.sh (Git-Bash) mit nsis-Bundle laufen lassen"
}
Start-Process -Wait $installer.FullName
Write-Output "installiert: $($installer.Name)"
+38 -34
View File
@@ -4,7 +4,7 @@
use crate::domain::paths::Paths; use crate::domain::paths::Paths;
use crate::domain::pool::provision_pools_for_panel; 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::domain::watcher::spawn_session_watcher;
use crate::platform::Anchor; use crate::platform::Anchor;
use crate::{commands, terminal}; use crate::{commands, terminal};
@@ -118,6 +118,10 @@ pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory); 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). // Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
spawn_session_watcher(); spawn_session_watcher();
@@ -232,7 +236,14 @@ pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
} }
} }
}) })
.invoke_handler(tauri::generate_handler![ .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::list_projects,
commands::create_project_full, commands::create_project_full,
commands::add_project, commands::add_project,
@@ -268,8 +279,29 @@ pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
commands::oauth_login, commands::oauth_login,
commands::keychain_status, commands::keychain_status,
commands::set_apikey, commands::set_apikey,
terminal::open_terminal 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 /// 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()) .plugin(tauri_plugin_clipboard_manager::init())
.manage(terminal::Terminals::default()) .manage(terminal::Terminals::default())
.setup(move |app| { .setup(move |app| {
let cfg = terminal_config(&project)?; let cfg = project_config(&project)?;
terminal::build_window(app.handle(), &project, &cfg)?; terminal::build_window(app.handle(), &project, &cfg)?;
Ok(()) Ok(())
}) })
@@ -297,33 +329,5 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
} }
} }
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(invoke_handlers())
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
])
} }
+26 -16
View File
@@ -14,9 +14,9 @@ use crate::domain::pool::{
}; };
use crate::domain::project::{ use crate::domain::project::{
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_project_in, 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, display_name_in, is_running, kill_terminals, list_projects_in, project_work_dirs_in,
remove_work_dir_in, resolve_icon_path, running_projects_using_pool, set_project_dir_in, read_project_config_in, remove_work_dir_in, resolve_icon_path, running_projects_using_pool,
set_terminal_config_in, unassign_pool_in, Project, TerminalConfig, set_project_dir_in, set_terminal_config_in, unassign_pool_in, Project, TerminalConfig,
}; };
use crate::domain::registry::unregister_project; use crate::domain::registry::unregister_project;
use crate::domain::settings; use crate::domain::settings;
@@ -41,7 +41,7 @@ pub(crate) fn create_project_full(
create_work_dir: bool, create_work_dir: bool,
terminal: TerminalConfig, terminal: TerminalConfig,
todo: bool, todo: bool,
) -> Result<(), String> { ) -> Result<String, String> {
create_project_full_in( create_project_full_in(
&Paths::real(), &Paths::real(),
&name, &name,
@@ -62,18 +62,20 @@ pub(crate) fn add_project(path: String) -> Result<(), String> {
/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet. /// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command] #[tauri::command]
pub(crate) fn remove_project(name: String) -> Result<(), String> { pub(crate) fn remove_project(project: String) -> Result<(), String> {
if is_running(&name) { if is_running(&project) {
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden")); 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. /// Projektordner neu zuordnen; bei laufender Session gesperrt.
#[tauri::command] #[tauri::command]
pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> { pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> {
if is_running(&project) { 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) 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] #[tauri::command]
pub(crate) fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> { pub(crate) fn delete_project(project: String, delete_work_dirs: bool) -> Result<(), String> {
if is_running(&name) { if is_running(&project) {
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden")); 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] #[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 { let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
return Ok(None); 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()) { let mime = match path.extension().and_then(|e| e.to_str()) {
Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml", Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml",
_ => "image/png", _ => "image/png",
@@ -168,6 +171,11 @@ pub(crate) fn rename_pool(pool: String, name: String) -> Result<(), String> {
rename_pool_in(&Paths::real(), &pool, &name) 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] #[tauri::command]
pub(crate) fn delete_pool(pool: String) -> Result<(), String> { pub(crate) fn delete_pool(pool: String) -> Result<(), String> {
let paths = Paths::real(); let paths = Paths::real();
@@ -175,7 +183,7 @@ pub(crate) fn delete_pool(pool: String) -> Result<(), String> {
if !running.is_empty() { if !running.is_empty() {
return Err(format!( return Err(format!(
"Pool wird noch benutzt — läuft: {}", "Pool wird noch benutzt — läuft: {}",
running.join(", ") display_names(&paths, &running)?.join(", ")
)); ));
} }
delete_pool_in(&paths, &KeychainStore, &pool) delete_pool_in(&paths, &KeychainStore, &pool)
@@ -219,7 +227,7 @@ pub(crate) fn oauth_login(pool: String) -> Result<(), String> {
if !running.is_empty() { if !running.is_empty() {
return Err(format!( return Err(format!(
"Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}", "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] #[tauri::command]
pub(crate) fn stop_project(project: String) -> Result<(), String> { pub(crate) fn stop_project(project: String) -> Result<(), String> {
if crate::platform::terminal_pids(&project).is_empty() { 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) 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); let deadline = Instant::now() + Duration::from_secs(30);
while is_running(&project) { while is_running(&project) {
if Instant::now() > deadline { 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)); std::thread::sleep(Duration::from_millis(250));
} }
+12 -5
View File
@@ -11,7 +11,7 @@ use crate::domain::project::{
}; };
use crate::domain::registry::project_dir; 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. /// Home-expandiert; None, wenn nicht gesetzt.
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> { pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
let paths = Paths::real(); let paths = Paths::real();
@@ -27,7 +27,7 @@ pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
Ok(home) 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 /// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
/// claude das Archiv später lesen/scannen darf. /// claude das Archiv später lesen/scannen darf.
pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> { 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. /// 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 sp = settings_path(&project_dir(paths, project)?);
let mut v: serde_json::Value = if sp.is_file() { 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()))?) 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())) 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 /// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus
/// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen. /// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen.
pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> { 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 (stamp, iso) = utc_stamp(secs);
let title = first_line(&text); let title = first_line(&text);
let path = free_path(&dir, &stamp, &slugify(&title)); 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()))?; fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(path) Ok(path)
} }
+2 -2
View File
@@ -33,8 +33,8 @@ impl Paths {
self.config_dir().join("pools") self.config_dir().join("pools")
} }
/// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config, /// Ehemals gemeinsames Icons-Verzeichnis aller Projekte — Icons liegen jetzt
/// unabhängig von Pool-Zuordnung und Quell-Repos. /// im .ai-control/-Ordner des Projekts; hiervon liest nur noch die Migration.
pub(crate) fn icons_dir(&self) -> PathBuf { pub(crate) fn icons_dir(&self) -> PathBuf {
self.config_dir().join("icons") self.config_dir().join("icons")
} }
+14 -6
View File
@@ -80,8 +80,16 @@ pub(crate) fn list_pools_in(
} }
_ => true, _ => true,
}; };
let projects = projects_using_pool(paths, &id)?; // Anzeige-Namen fürs Frontend; die Lauf-Erkennung braucht die IDs.
let running = projects.iter().filter(|p| is_running(p)).cloned().collect(); 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 { pools.push(PoolInfo {
id, id,
projects, projects,
@@ -486,6 +494,7 @@ mod tests {
use crate::domain::project::{ use crate::domain::project::{
assign_pool_in, read_project_config_in, set_terminal_config_in, TerminalConfig, 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::settings::APP_SETTINGS_FILE;
use crate::domain::testutil::{ use crate::domain::testutil::{
create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore, 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.name, "kunde-neu");
assert_eq!(pool.credential_type, "apikey"); assert_eq!(pool.credential_type, "apikey");
assert!(p.pool_dir(&id).is_dir()); assert!(p.pool_dir(&id).is_dir());
let cfg = read_project_config_in(&p, "proj").unwrap(); assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(id.as_str()));
assert_eq!(cfg.pool.as_deref(), Some(id.as_str()));
} }
#[test] #[test]
@@ -772,7 +780,7 @@ mod tests {
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
delete_pool_in(&p, &store, &kunde).unwrap(); delete_pool_in(&p, &store, &kunde).unwrap();
assert!(!p.pool_dir(&kunde).exists()); assert!(!p.pool_dir(&kunde).exists());
assert!(!crate::domain::project::project_config_path(&p, "proj").unwrap().exists()); assert_eq!(project_pool(&p, "proj").unwrap(), None);
} }
#[test] #[test]
@@ -824,8 +832,8 @@ mod tests {
) )
.unwrap(); .unwrap();
delete_pool_in(&p, &store, &kunde).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(); let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.pool, None);
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
} }
+508 -158
View File
@@ -1,21 +1,38 @@
//! Projekte: Anlage (Wizard), Registry-Aufnahme, Arbeitsordner, Pool-Zuordnung, //! Projekte: Anlage (Wizard), Registry-Aufnahme, Arbeitsordner, Pool-Zuordnung,
//! Terminal-Einstellungen, Löschen. Der Projektordner trägt .claude/settings.json //! 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 serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::domain::check_name; use crate::domain::check_name;
use crate::domain::paths::{contract_home, expand_home, Paths}; use crate::domain::paths::{contract_home, expand_home, Paths};
use crate::domain::pool::POOL_FILE; 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). /// Projekt-eigener Config-Ordner im Projekt-Root: config.json + Icon.
pub(crate) const PROJECT_FILE: &str = "ai-control.json"; 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)] #[derive(Serialize)]
pub(crate) struct Project { pub(crate) struct Project {
pub(crate) id: String,
pub(crate) name: String, pub(crate) name: String,
pub(crate) path: String, pub(crate) path: String,
pub(crate) pool: Option<String>, pub(crate) pool: Option<String>,
@@ -25,8 +42,13 @@ pub(crate) struct Project {
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
pub(crate) struct ProjectConfig { 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")] #[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")] #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
pub(crate) terminal: TerminalConfig, pub(crate) terminal: TerminalConfig,
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen /// 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> { 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( pub(crate) fn read_project_config_in(
@@ -94,7 +116,9 @@ pub(crate) fn write_project_config_in(
cfg: &ProjectConfig, cfg: &ProjectConfig,
) -> Result<(), String> { ) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?; let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
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())) fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
} }
@@ -122,65 +146,91 @@ pub(crate) fn running_projects_using_pool(
.collect()) .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> { pub(crate) fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
let mut users = Vec::new(); Ok(
for (name, dir) in load_registry(paths)? { load_registry(paths)?
let cfg_path = dir.join(PROJECT_FILE); .into_iter()
if !cfg_path.is_file() { .filter(|(_, e)| e.pool.as_deref() == Some(pool))
continue; .map(|(name, _)| name)
} .collect(),
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)
} }
pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> { pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
let mut projects = Vec::new(); let mut projects = Vec::new();
for (name, dir) in load_registry(paths)? { for (id, entry) in load_registry(paths)? {
let cfg = read_project_config_in(paths, &name)?; let cfg = read_project_config_in(paths, &id)?;
projects.push(Project { projects.push(Project {
running: is_running(&name), running: is_running(&id),
path: contract_home(paths, &dir), path: contract_home(paths, &entry.dir),
pool: cfg.pool, pool: entry.pool,
name: cfg.name.unwrap_or_else(|| id.clone()),
terminal: cfg.terminal, terminal: cfg.terminal,
name, id,
}); });
} }
Ok(projects) 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 /// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben. /// CLAUDE_CONFIG_DIR mitgegeben.
/// ///
/// Der Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon tut: /// Der Pool-Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon
/// Die Quelle ist die `ai-control.json` **im Projektordner**, also eine /// tut: Die Migration übernimmt Pool-Einträge aus der alten, versionierten
/// versionierte Datei, die mit einem geklonten Repo hereinkommt. Ungeprüft /// ai-control.json eines geklonten Repos. Ungeprüft bestimmte ein `../…`
/// bestimmte sie mit `../…` ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR — /// daraus ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR — und dessen
/// und dessen `settings.json` trägt `apiKeyHelper`, ein Kommando, das beim /// `settings.json` trägt `apiKeyHelper`, ein Kommando, das beim Sessionstart
/// Sessionstart ausgeführt wird. /// ausgeführt wird.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> { pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
let paths = Paths::real(); project_pool_dir_in(&Paths::real(), project)
let Some(pool) = read_project_config_in(&paths, project)?.pool else { }
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); 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))) Ok(Some(paths.pool_dir(&pool)))
} }
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. /// Projekt-Config für den Terminal-Prozess (Terminal-Einstellungen + Name).
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> { pub(crate) fn project_config(project: &str) -> Result<ProjectConfig, String> {
Ok(read_project_config_in(&Paths::real(), project)?.terminal) read_project_config_in(&Paths::real(), project)
} }
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore /// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, /// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag. /// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>. /// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) fn create_project_full_in( pub(crate) fn create_project_full_in(
paths: &Paths, paths: &Paths,
@@ -191,14 +241,14 @@ pub(crate) fn create_project_full_in(
create_work_dir: bool, create_work_dir: bool,
terminal: TerminalConfig, terminal: TerminalConfig,
todo: bool, todo: bool,
) -> Result<(), String> { ) -> Result<String, String> {
check_name(name)?; check_name(name)?;
let dir = match dir { let dir = match dir {
Some(d) => expand_home(paths, d), Some(d) => expand_home(paths, d),
None => paths.projects_dir().join(name), None => paths.projects_dir().join(name),
}; };
let mut reg = load_registry(paths)?; 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}")); return Err(format!("Projekt existiert bereits: {name}"));
} }
if dir.exists() { if dir.exists() {
@@ -245,21 +295,22 @@ pub(crate) fn create_project_full_in(
fs::write(dir.join(".claude").join("settings.json"), raw + "\n") fs::write(dir.join(".claude").join("settings.json"), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?; .map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
reg.insert(name.to_string(), dir.clone()); let id = uuid::Uuid::new_v4().to_string();
reg.insert(id.clone(), RegEntry { dir: dir.clone(), pool: pool.map(str::to_string) });
save_registry(paths, &reg)?; save_registry(paths, &reg)?;
let cfg = ProjectConfig { let cfg = ProjectConfig {
pool: pool.map(str::to_string), id: Some(id.clone()),
name: Some(name.to_string()),
terminal, terminal,
..ProjectConfig::default() archive_home: None,
rest: Default::default(),
}; };
if cfg.pool.is_some() || !cfg.terminal.is_empty() { write_project_config_in(paths, &id, &cfg)?;
write_project_config_in(paths, name, &cfg)?;
}
if todo { 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); crate::platform::write_terminal_desktop(paths, &id, &cfg);
Ok(()) Ok(id)
} }
/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise /// 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())); return Err(format!("Ordner nicht gefunden: {}", new_dir.display()));
} }
let mut reg = load_registry(paths)?; let mut reg = load_registry(paths)?;
let old_dir = reg let entry = reg
.get(name) .get_mut(name)
.cloned()
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?; .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
let old_dir = entry.dir.clone();
if old_dir == new_dir { if old_dir == new_dir {
return Ok(()); return Ok(());
} }
reg.insert(name.to_string(), new_dir.clone()); entry.dir = new_dir.clone();
save_registry(paths, &reg)?; save_registry(paths, &reg)?;
let sp = settings_path(&new_dir); 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())) 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> { pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
let dir = expand_home(paths, path); let dir = expand_home(paths, path);
if !dir.is_dir() { if !dir.is_dir() {
return Err(format!("Ordner nicht gefunden: {}", dir.display())); return Err(format!("Ordner nicht gefunden: {}", dir.display()));
} }
let name = dir let dirname = dir
.file_name() .file_name()
.ok_or_else(|| format!("kein Ordnername: {}", dir.display()))? .ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
.to_string_lossy() .to_string_lossy()
.into_owned(); .into_owned();
check_name(&name)?; check_name(&dirname)?;
register_project(paths, &name, &dir)?;
let cfg = read_project_config_in(paths, &name) let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE);
.map(|c| c.terminal) let mut cfg: ProjectConfig = if cfg_path.is_file() {
.unwrap_or_default(); let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
crate::platform::write_terminal_desktop(paths, &name, &cfg); 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(()) 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() { if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
return Err(format!("Pool existiert nicht: {pool}")); return Err(format!("Pool existiert nicht: {pool}"));
} }
let mut cfg = read_project_config_in(paths, project)?; set_project_pool(paths, project, Some(pool))
cfg.pool = Some(pool.to_string());
write_project_config_in(paths, project, &cfg)
} }
/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch /// Nimmt die Pool-Zuordnung aus der Registry.
/// Terminal-Einstellungen trägt.
pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?; if project_pool(paths, project)?.is_none() {
cfg.pool = None; return Err(format!("kein Pool zugeordnet: {project}"));
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()));
} }
write_project_config_in(paths, project, &cfg) set_project_pool(paths, project, None)
} }
pub(crate) fn set_terminal_config_in( pub(crate) fn set_terminal_config_in(
@@ -472,8 +545,8 @@ pub(crate) fn set_terminal_config_in(
mut terminal: TerminalConfig, mut terminal: TerminalConfig,
) -> Result<(), String> { ) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?; let mut cfg = read_project_config_in(paths, project)?;
// Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als // Absolut gewählte Icons in den Projekt-Config-Ordner kopieren und als
// Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo. // Dateiname speichern — das Icon synct damit mit dem Projekt.
if let Some(icon) = terminal.icon.as_deref() { if let Some(icon) = terminal.icon.as_deref() {
if icon.starts_with('/') { if icon.starts_with('/') {
let src = PathBuf::from(icon); let src = PathBuf::from(icon);
@@ -482,10 +555,10 @@ pub(crate) fn set_terminal_config_in(
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.unwrap_or("png") .unwrap_or("png")
.to_lowercase(); .to_lowercase();
let name = format!("{project}.{ext}"); let name = format!("icon.{ext}");
let icons = paths.icons_dir(); let cfg_dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR);
fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?; fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let dest = icons.join(&name); let dest = cfg_dir.join(&name);
if src != dest { if src != dest {
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
} }
@@ -497,67 +570,126 @@ pub(crate) fn set_terminal_config_in(
terminal.rest = std::mem::take(&mut cfg.terminal.rest); terminal.rest = std::mem::take(&mut cfg.terminal.rest);
cfg.terminal = terminal; cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)?; 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(()) Ok(())
} }
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im /// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// gemeinsamen Icons-Verzeichnis der App. /// Projekt-Config-Ordner (.ai-control) des Projekts.
pub(crate) fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf { pub(crate) fn resolve_icon_path(
paths: &Paths,
project: &str,
icon: &str,
) -> Result<PathBuf, String> {
if icon.starts_with('/') { if icon.starts_with('/') {
PathBuf::from(icon) Ok(PathBuf::from(icon))
} else { } else {
paths.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths}; use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE; use crate::domain::todo::TODO_FILE;
/// Der read-modify-write darf keine Keys verlieren, die dieser Build nicht /// Ein Pool-Eintrag mit Pfad-Bestandteilen — etwa per Migration aus der
/// kennt. Genau daran ging `archiveHome` verloren: Ein älterer Build ohne das /// mitgeklonten Alt-Datei in die Registry gelangt — darf kein beliebiges
/// Feld hat bei der Pool-Zuweisung die ganze Datei neu geschrieben. /// Verzeichnis zum CLAUDE_CONFIG_DIR machen.
#[test] #[test]
fn pool_zuweisen_erhaelt_fremde_keys() { fn pool_mit_traversal_wird_abgelehnt() {
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() {
let p = tmp_paths(); let p = tmp_paths();
create_project(&p, "fremd").unwrap(); create_project(&p, "fremd").unwrap();
let cfg_path = project_config_path(&p, "fremd").unwrap(); crate::domain::registry::set_project_pool(&p, "fremd", Some("../../../../tmp/x")).unwrap();
fs::write(&cfg_path, r#"{"pool":"../../../../tmp/x"}"#).unwrap();
let cfg = read_project_config_in(&p, "fremd").unwrap(); let err = project_pool_dir_in(&p, "fremd").unwrap_err();
assert_eq!(cfg.pool.as_deref(), Some("../../../../tmp/x")); assert!(err.contains("ungültiger Pool"));
// Gelesen wird der Wert noch, aber er darf keinen Pfad bilden.
assert!(check_name(cfg.pool.as_deref().unwrap()).is_err());
} }
/// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur /// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur
@@ -586,7 +718,7 @@ mod tests {
fn projekt_wizard_scaffold() { fn projekt_wizard_scaffold() {
let p = tmp_paths(); let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project_full_in( let id = create_project_full_in(
&p, &p,
"neu", "neu",
None, None,
@@ -610,10 +742,17 @@ mod tests {
); );
assert!(p.home.join("projects").join("neu").is_dir()); assert!(p.home.join("projects").join("neu").is_dir());
let cfg = read_project_config_in(&p, "neu").unwrap(); // ID ist UUID und Registry-Schlüssel; Pool liegt in der Registry.
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); 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.title.as_deref(), Some("Neu"));
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); 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( let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
@@ -639,11 +778,19 @@ mod tests {
#[test] #[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() { fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "neu", None, None, 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"); let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file()); assert!(dir.join(".claude").join("settings.json").is_file());
// ohne Pool und Terminal-Config entsteht keine ai-control.json // config.json entsteht immer — sie trägt die Projekt-Identität.
assert!(!project_config_path(&p, "neu").unwrap().is_file()); 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( let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
) )
@@ -653,7 +800,7 @@ mod tests {
} }
#[test] #[test]
fn projekt_wizard_doppelt_scheitert() { fn projekt_wizard_doppelter_name_scheitert() {
let p = tmp_paths(); let p = tmp_paths();
create_project(&p, "neu").unwrap(); create_project(&p, "neu").unwrap();
let err = let err =
@@ -690,10 +837,10 @@ mod tests {
create_project(&p, "proj").unwrap(); create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
let cfg: ProjectConfig = assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(kunde.as_str()));
serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap()) // die Zuordnung ist maschinenlokal — die Projekt-Config bleibt ohne pool-Key
.unwrap(); let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap();
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); assert!(!raw.contains("pool"));
let pools = let pools =
crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap(); crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap();
@@ -714,7 +861,7 @@ mod tests {
create_project(&p, "proj").unwrap(); create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
unassign_pool_in(&p, "proj").unwrap(); unassign_pool_in(&p, "proj").unwrap();
assert!(!project_config_path(&p, "proj").unwrap().exists()); assert_eq!(project_pool(&p, "proj").unwrap(), None);
} }
#[test] #[test]
@@ -736,12 +883,154 @@ mod tests {
assign_pool_in(&p, "proj", &privat).unwrap(); assign_pool_in(&p, "proj", &privat).unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap(); assign_pool_in(&p, "proj", &kunde).unwrap();
let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap(); assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(kunde.as_str()));
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
} }
// -- 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] #[test]
fn projekt_anlegen() { fn projekt_anlegen() {
@@ -751,10 +1040,62 @@ mod tests {
let projects = list_projects_in(&p).unwrap(); let projects = list_projects_in(&p).unwrap();
assert_eq!(projects.len(), 1); assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id, "proj");
assert_eq!(projects[0].name, "proj"); assert_eq!(projects[0].name, "proj");
assert_eq!(projects[0].pool, None); 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] #[test]
fn projekt_doppelt_anlegen_scheitert() { fn projekt_doppelt_anlegen_scheitert() {
let p = tmp_paths(); let p = tmp_paths();
@@ -773,9 +1114,9 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_laesst_arbeitsordner() { fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, 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(); .unwrap();
delete_project_in(&p, "proj", false).unwrap(); delete_project_in(&p, &id, false).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
assert!(p.home.join("projects").join("proj").is_dir()); assert!(p.home.join("projects").join("proj").is_dir());
} }
@@ -783,9 +1124,9 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_mit_arbeitsordner() { fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, 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(); .unwrap();
delete_project_in(&p, "proj", true).unwrap(); delete_project_in(&p, &id, true).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
assert!(!p.home.join("projects").join("proj").exists()); assert!(!p.home.join("projects").join("proj").exists());
} }
@@ -793,39 +1134,46 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, 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(); .unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_in(&p, "proj", true).is_err()); assert!(delete_project_in(&p, &id, true).is_err());
} }
#[test] #[test]
fn projekt_arbeitsordner_auslesen() { fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths(); let p = tmp_paths();
create_project_full_in(&p, "proj", None, 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(); .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 // Projekt ohne settings.json → leer
create_project(&p, "alt").unwrap(); let dir = p.home.join("ohne-settings");
assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty()); 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] #[test]
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() { fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
let p = tmp_paths(); 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(); .unwrap();
fs::create_dir_all(p.home.join("projects/extra")).unwrap(); fs::create_dir_all(p.home.join("projects/extra")).unwrap();
let picked = p.home.join("projects/extra").to_string_lossy().into_owned(); 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 // 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(); let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(raw.contains("Edit(~/projects/extra/**)")); 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(); remove_work_dir_in(&p, &id, "~/projects/extra").unwrap();
assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty()); assert!(project_work_dirs_in(&p, &id).unwrap().is_empty());
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(!raw.contains("~/projects/extra")); assert!(!raw.contains("~/projects/extra"));
// Ordner selbst bleibt // Ordner selbst bleibt
@@ -835,15 +1183,17 @@ mod tests {
#[test] #[test]
fn projektordner_verlegen() { fn projektordner_verlegen() {
let p = tmp_paths(); 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(); .unwrap();
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu. // Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
let new_dir = p.home.join("elsewhere").join("proj"); let new_dir = p.home.join("elsewhere").join("proj");
fs::create_dir_all(p.home.join("elsewhere")).unwrap(); fs::create_dir_all(p.home.join("elsewhere")).unwrap();
fs::rename(p.projects_dir().join("proj"), &new_dir).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(); let raw = fs::read_to_string(settings_path(&new_dir)).unwrap();
assert!(raw.contains("~/elsewhere/proj/memory")); assert!(raw.contains("~/elsewhere/proj/memory"));
assert!(raw.contains("Edit(~/elsewhere/proj/**)")); assert!(raw.contains("Edit(~/elsewhere/proj/**)"));
+76 -13
View File
@@ -1,26 +1,53 @@
//! Projekt-Registry: projects.json (Name → Ordner) unter ~/.config/ai-control. //! Projekt-Registry: projects.json (Projekt-ID → Ordner + Pool) unter
//! ~/.config/ai-control. Schlüssel ist die Projekt-UUID aus der
//! .ai-control/config.json des Projekts; die Pool-Zuordnung ist
//! maschinenlokal und lebt deshalb hier, nicht in der syncbaren
//! Projekt-Config.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::domain::paths::{contract_home, expand_home, Paths}; use crate::domain::paths::{contract_home, expand_home, Paths};
/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte. /// Registry-Eintrag eines Projekts: Ordner + Pool-Zuordnung dieser Maschine.
pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, PathBuf>, String> { 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(); let file = paths.projects_file();
if !file.is_file() { if !file.is_file() {
return Ok(BTreeMap::new()); return Ok(BTreeMap::new());
} }
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; 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()))?; serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
Ok( Ok(
map map
.into_iter() .into_iter()
.map(|(name, p)| { .map(|(name, e)| {
let dir = expand_home(paths, &p); let (path, pool) = match e {
(name, dir) RawEntry::Path(p) => (p, None),
RawEntry::Full { path, pool } => (path, pool),
};
(name, RegEntry { dir: expand_home(paths, &path), pool })
}) })
.collect(), .collect(),
) )
@@ -28,11 +55,20 @@ pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, PathBuf>,
pub(crate) fn save_registry( pub(crate) fn save_registry(
paths: &Paths, paths: &Paths,
reg: &BTreeMap<String, PathBuf>, reg: &BTreeMap<String, RegEntry>,
) -> Result<(), String> { ) -> Result<(), String> {
let contracted: BTreeMap<&String, String> = let raw_map: BTreeMap<&String, RawEntry> = reg
reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect(); .iter()
let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?; .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()) fs::create_dir_all(paths.config_dir())
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
let file = paths.projects_file(); let file = paths.projects_file();
@@ -41,9 +77,36 @@ pub(crate) fn save_registry(
/// Ordner eines registrierten Projekts. /// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> { pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
Ok(
load_registry(paths)? load_registry(paths)?
.remove(name) .remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}")) .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?
.dir,
)
}
/// Pool-Zuordnung eines registrierten Projekts (maschinenlokal).
pub(crate) fn project_pool(paths: &Paths, name: &str) -> Result<Option<String>, String> {
Ok(
load_registry(paths)?
.remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?
.pool,
)
}
/// Setzt bzw. löscht die Pool-Zuordnung eines Projekts in der Registry.
pub(crate) fn set_project_pool(
paths: &Paths,
name: &str,
pool: Option<&str>,
) -> Result<(), String> {
let mut reg = load_registry(paths)?;
let entry = reg
.get_mut(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
entry.pool = pool.map(str::to_string);
save_registry(paths, &reg)
} }
/// Nimmt ein Projekt in die Registry auf. /// Nimmt ein Projekt in die Registry auf.
@@ -56,7 +119,7 @@ pub(crate) fn register_project(
if reg.contains_key(name) { if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}")); return Err(format!("Projekt existiert bereits: {name}"));
} }
reg.insert(name.to_string(), dir.to_path_buf()); reg.insert(name.to_string(), RegEntry { dir: dir.to_path_buf(), pool: None });
save_registry(paths, &reg) save_registry(paths, &reg)
} }
+10 -1
View File
@@ -75,7 +75,9 @@ pub(crate) fn make_apikey_pool(
create_apikey_pool_in(p, store, name, key, true).unwrap() 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> { pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> {
crate::domain::check_name(name)?; crate::domain::check_name(name)?;
let dir = paths.projects_dir().join(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")) fs::create_dir_all(dir.join(".claude"))
.map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?; .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) register_project(paths, name, &dir)
} }
+7 -6
View File
@@ -129,17 +129,18 @@ mod tests {
#[test] #[test]
fn todo_zuschalten_und_abschalten() { fn todo_zuschalten_und_abschalten() {
let p = tmp_paths(); let p = tmp_paths();
let id =
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap(); .unwrap();
assert!(!todo_state_in(&p, "proj").unwrap()); assert!(!todo_state_in(&p, &id).unwrap());
set_todo_in(&p, "proj", true).unwrap(); set_todo_in(&p, &id, true).unwrap();
assert!(todo_state_in(&p, "proj").unwrap()); assert!(todo_state_in(&p, &id).unwrap());
let todo_path = p.projects_dir().join("proj").join(TODO_FILE); let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON); assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
// doppelt aktivieren erzeugt keinen zweiten Hook // 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( let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(), &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 // Abschalten: Hook weg, Datei (mit Inhalt) bleibt
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap(); fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
set_todo_in(&p, "proj", false).unwrap(); set_todo_in(&p, &id, false).unwrap();
assert!(!todo_state_in(&p, "proj").unwrap()); assert!(!todo_state_in(&p, &id).unwrap());
assert!(todo_path.is_file()); assert!(todo_path.is_file());
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig")); assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
} }
+3 -2
View File
@@ -138,8 +138,9 @@ pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>,
// kodierter Projektpfad → Projektname // kodierter Projektpfad → Projektname
let mut names = HashMap::new(); let mut names = HashMap::new();
for (name, dir) in load_registry(paths)? { for (id, entry) in load_registry(paths)? {
names.insert(encode_project_path(&dir.to_string_lossy()), name); 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(); let mut seen = HashSet::new();
+6 -4
View File
@@ -5,7 +5,7 @@ use std::process::Command;
use std::time::Duration; use std::time::Duration;
use crate::domain::paths::Paths; 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::registry::project_dir;
use crate::domain::settings::sync_on_session_end; 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()) .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ü. /// Projektliste; Grundlage für Session-Watcher und Tray-Menü.
fn project_state(paths: &Paths) -> Vec<(String, bool)> { fn project_state(paths: &Paths) -> Vec<(String, bool)> {
let mut state: Vec<(String, bool)> = list_projects_in(paths) 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(); .unwrap_or_default();
state.sort(); state.sort();
state state
@@ -98,7 +98,9 @@ pub(crate) fn spawn_session_watcher() {
for project in ended { for project in ended {
if let Ok(dir) = project_dir(&paths, project) { if let Ok(dir) = project_dir(&paths, project) {
if let Some(repo) = git_root(&dir) { if let Some(repo) = git_root(&dir) {
sync_session_context(&repo, project); let name =
display_name_in(&paths, project).unwrap_or_else(|_| project.clone());
sync_session_context(&repo, &name);
} }
} }
} }
+4 -2
View File
@@ -72,11 +72,13 @@ pub fn run() {
// Eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag // Eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag
// (muss vor dem GTK-Init stehen; No-op außerhalb Linux). // (muss vor dem GTK-Init stehen; No-op außerhalb Linux).
platform::set_app_id(&format!("aicontrol-{project}")); 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") .expect("Projekt-Config nicht lesbar")
.terminal
.icon .icon
.map(|i| { .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() .to_string_lossy()
.into_owned() .into_owned()
}); });
+9 -8
View File
@@ -1,11 +1,12 @@
//! Pro-Projekt-.desktop-Dateien: dash-to-dock/GNOME ordnen dem Fenster //! 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::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::domain::paths::Paths; 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; use crate::domain::registry::load_registry;
/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien. /// ~/.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 /// Schreibt/aktualisiert die .desktop eines Projekts. NoDisplay=true hält den
/// App-Starter sauber. /// 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); let dir = applications_dir(paths);
if fs::create_dir_all(&dir).is_err() { if fs::create_dir_all(&dir).is_err() {
return; 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)) .and_then(|p| p.to_str().map(str::to_string))
.unwrap_or_else(|| "ai-control".into()); .unwrap_or_else(|| "ai-control".into());
let icon_line = cfg let icon_line = cfg
.terminal
.icon .icon
.as_deref() .as_deref()
.map(|i| resolve_icon_path(paths, i)) .and_then(|i| resolve_icon_path(paths, project, i).ok())
.filter(|p| p.exists()) .filter(|p| p.exists())
.and_then(|p| p.to_str().map(str::to_string)) .and_then(|p| p.to_str().map(str::to_string))
.map(|p| format!("Icon={p}\n")) .map(|p| format!("Icon={p}\n"))
.unwrap_or_default(); .unwrap_or_default();
let name = cfg.name.as_deref().unwrap_or(project);
let content = format!( 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" {icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n"
); );
let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content); let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content);
@@ -53,9 +56,7 @@ pub(crate) fn sync_all_desktops(paths: &Paths) {
return; return;
}; };
for project in reg.keys() { for project in reg.keys() {
let cfg = read_project_config_in(paths, project) let cfg = read_project_config_in(paths, project).unwrap_or_default();
.map(|c| c.terminal)
.unwrap_or_default();
write_terminal_desktop(paths, project, &cfg); write_terminal_desktop(paths, project, &cfg);
} }
let dir = applications_dir(paths); let dir = applications_dir(paths);
+2 -2
View File
@@ -7,7 +7,7 @@ use std::process::Command;
use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE}; use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE};
use crate::domain::paths::Paths; use crate::domain::paths::Paths;
use crate::domain::pool::APIKEY_FILE; use crate::domain::pool::APIKEY_FILE;
use crate::domain::project::TerminalConfig; use crate::domain::project::ProjectConfig;
use crate::platform::{Anchor, TrayCallbacks}; use crate::platform::{Anchor, TrayCallbacks};
// ---------- Aktivierung / Fokus / Icons ---------- // ---------- Aktivierung / Fokus / Icons ----------
@@ -83,7 +83,7 @@ pub(crate) fn reveal_path(path: &Path) {
// ---------- Desktop-Integration (nur Linux substantiell) ---------- // ---------- 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 remove_terminal_desktop(_paths: &Paths, _project: &str) {}
pub(crate) fn sync_all_desktops(_paths: &Paths) {} pub(crate) fn sync_all_desktops(_paths: &Paths) {}
+2 -2
View File
@@ -9,7 +9,7 @@ use std::process::Command;
use crate::domain::paths::Paths; use crate::domain::paths::Paths;
use crate::domain::pool::APIKEY_FILE; use crate::domain::pool::APIKEY_FILE;
use crate::domain::project::TerminalConfig; use crate::domain::project::ProjectConfig;
use crate::platform::{Anchor, TrayCallbacks}; use crate::platform::{Anchor, TrayCallbacks};
// ---------- Prozesse ---------- // ---------- Prozesse ----------
@@ -77,7 +77,7 @@ pub(crate) fn symlink(_target: &Path, _link: &Path) -> Result<(), String> {
// ---------- Desktop-Integration (nur Linux substantiell) ---------- // ---------- 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 remove_terminal_desktop(_paths: &Paths, _project: &str) {}
pub(crate) fn sync_all_desktops(_paths: &Paths) {} pub(crate) fn sync_all_desktops(_paths: &Paths) {}
+23 -8
View File
@@ -6,7 +6,9 @@ use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths}; 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::registry::project_dir;
use crate::domain::settings::claude_command; 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. /// das Terminal-Fenster hinter der aktiven App.
#[tauri::command] #[tauri::command]
pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> { 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() { if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display())); return Err(format!("Projektordner fehlt: {}", dir.display()));
} }
@@ -61,10 +66,15 @@ fn theme_background(theme: &str) -> (u8, u8, u8) {
pub fn build_window( pub fn build_window(
app: &AppHandle, app: &AppHandle,
project: &str, project: &str,
cfg: &TerminalConfig, cfg: &ProjectConfig,
) -> tauri::Result<()> { ) -> tauri::Result<()> {
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); let (r, g, b) = theme_background(cfg.terminal.theme.as_deref().unwrap_or_default());
let title = cfg.title.as_deref().unwrap_or(project); let title = cfg
.terminal
.title
.as_deref()
.or(cfg.name.as_deref())
.unwrap_or(project);
let builder = WebviewWindowBuilder::new( let builder = WebviewWindowBuilder::new(
app, app,
format!("term-{project}"), format!("term-{project}"),
@@ -369,9 +379,14 @@ pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), St
let _ = w.set_focus(); let _ = w.set_focus();
return Ok(()); return Ok(());
} }
let cfg = terminal_config(&project)?; let cfg = project_config(&project)?;
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); let (r, g, b) = theme_background(cfg.terminal.theme.as_deref().unwrap_or_default());
let title = cfg.title.as_deref().unwrap_or(project.as_str()); let title = cfg
.terminal
.title
.as_deref()
.or(cfg.name.as_deref())
.unwrap_or(project.as_str());
let builder = WebviewWindowBuilder::new( let builder = WebviewWindowBuilder::new(
&app, &app,
&label, &label,
+7 -6
View File
@@ -6,6 +6,7 @@ import { currentMonitor } from "@tauri-apps/api/window";
import { LogicalSize } from "@tauri-apps/api/dpi"; import { LogicalSize } from "@tauri-apps/api/dpi";
interface Project { interface Project {
id: string;
name: string; name: string;
running: boolean; running: boolean;
terminal: { theme: string | null; icon: string | null; title: string | null }; terminal: { theme: string | null; icon: string | null; title: string | null };
@@ -25,9 +26,9 @@ async function refresh() {
try { try {
projects.value = await invoke<Project[]>("list_projects"); projects.value = await invoke<Project[]>("list_projects");
for (const p of projects.value) { for (const p of projects.value) {
if (p.terminal.icon && !(p.name in icons.value)) { if (p.terminal.icon && !(p.id in icons.value)) {
icons.value[p.name] = icons.value[p.id] =
(await invoke<string | null>("project_icon", { project: p.name })) ?? ""; (await invoke<string | null>("project_icon", { project: p.id })) ?? "";
} }
} }
} catch (e) { } catch (e) {
@@ -36,7 +37,7 @@ async function refresh() {
} }
async function pick(p: Project) { 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(); await win.hide();
} }
async function openMain() { async function openMain() {
@@ -72,9 +73,9 @@ onUnmounted(() => {
<template> <template>
<div ref="wrapRef" class="wrap" :style="{ maxHeight: maxH + 'px' }"> <div ref="wrapRef" class="wrap" :style="{ maxHeight: maxH + 'px' }">
<ul class="list"> <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> <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 v-else class="ic ph"></span>
<span class="nm">{{ p.name }}</span> <span class="nm">{{ p.name }}</span>
</li> </li>
+31 -26
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
interface Project { interface Project {
id: string;
name: string; name: string;
path: string; path: string;
pool: string | null; pool: string | null;
@@ -44,7 +45,7 @@ const error = ref("");
const icons = ref<Record<string, string>>({}); const icons = ref<Record<string, string>>({});
function iconKey(p: Project): string | null { 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 { function projIcon(p: Project): string | undefined {
@@ -58,7 +59,7 @@ async function loadIcons() {
if (!key || key in icons.value) continue; if (!key || key in icons.value) continue;
try { try {
const data = await invoke<string | null>("project_icon", { const data = await invoke<string | null>("project_icon", {
project: p.name, project: p.id,
}); });
icons.value[key] = data ?? ""; icons.value[key] = data ?? "";
} catch (e) { } catch (e) {
@@ -81,7 +82,7 @@ async function refresh() {
async function stop(project: Project) { async function stop(project: Project) {
try { try {
await invoke("stop_project", { project: project.name }); await invoke("stop_project", { project: project.id });
await refresh(); await refresh();
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
@@ -90,7 +91,7 @@ async function stop(project: Project) {
async function openTerminal(project: Project) { async function openTerminal(project: Project) {
try { try {
await invoke("open_terminal", { project: project.name }); await invoke("open_terminal", { project: project.id });
await refresh(); await refresh();
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
@@ -98,6 +99,7 @@ async function openTerminal(project: Project) {
} }
interface PendingRestart { interface PendingRestart {
id: string;
name: string; name: string;
from: string | null; from: string | null;
to: string; to: string;
@@ -110,9 +112,9 @@ async function assign(project: Project, event: Event) {
const pool = (event.target as HTMLSelectElement).value; const pool = (event.target as HTMLSelectElement).value;
const from = project.pool; const from = project.pool;
try { try {
await invoke("assign_pool", { project: project.name, pool }); await invoke("assign_pool", { project: project.id, pool });
if (project.running && pool !== from) { 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(); await refresh();
} catch (e) { } catch (e) {
@@ -124,7 +126,7 @@ async function restartNow() {
const p = pending.value!; const p = pending.value!;
restarting.value = true; restarting.value = true;
try { try {
await invoke("restart_project", { project: p.name }); await invoke("restart_project", { project: p.id });
pending.value = null; pending.value = null;
await refresh(); await refresh();
} catch (e) { } catch (e) {
@@ -151,6 +153,7 @@ const THEME_NAMES: [string, string][] = [
]; ];
interface TerminalSettings { interface TerminalSettings {
id: string;
name: string; name: string;
path: string; path: string;
theme: string; theme: string;
@@ -165,14 +168,15 @@ const settings = ref<TerminalSettings | null>(null);
async function openSettings(p: Project) { async function openSettings(p: Project) {
try { 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", { const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.name, project: p.id,
}); });
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", { const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: p.name, project: p.id,
}); });
settings.value = { settings.value = {
id: p.id,
name: p.name, name: p.name,
path: p.path, path: p.path,
theme: p.terminal.theme ?? "mocha", theme: p.terminal.theme ?? "mocha",
@@ -194,9 +198,9 @@ async function changeProjectDir() {
const dir = await open({ directory: true, multiple: false }); const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return; if (typeof dir !== "string") return;
try { try {
await invoke("set_project_dir", { project: s.name, dir }); await invoke("set_project_dir", { project: s.id, dir });
await refresh(); 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; if (p) s.path = p.path;
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
@@ -210,8 +214,8 @@ async function addWorkDir() {
const dir = await open({ directory: true, multiple: false }); const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return; if (typeof dir !== "string") return;
try { try {
await invoke("add_work_dir", { project: s.name, dir }); await invoke("add_work_dir", { project: s.id, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.name }); s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.id });
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
} }
@@ -220,8 +224,8 @@ async function addWorkDir() {
async function removeWorkDir(dir: string) { async function removeWorkDir(dir: string) {
const s = settings.value!; const s = settings.value!;
try { try {
await invoke("remove_work_dir", { project: s.name, dir }); await invoke("remove_work_dir", { project: s.id, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.name }); s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.id });
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
} }
@@ -235,9 +239,9 @@ async function chooseArchive() {
const dir = await open({ directory: true, multiple: false }); const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return; if (typeof dir !== "string") return;
try { 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", { s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: s.name, project: s.id,
}); });
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
@@ -247,7 +251,7 @@ async function chooseArchive() {
async function clearArchive() { async function clearArchive() {
const s = settings.value!; const s = settings.value!;
try { try {
await invoke("clear_archive_home_cmd", { project: s.name }); await invoke("clear_archive_home_cmd", { project: s.id });
s.archiveHome = null; s.archiveHome = null;
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
@@ -268,12 +272,12 @@ async function saveSettings() {
try { try {
const title = s.title.trim(); const title = s.title.trim();
await invoke("set_terminal_config", { await invoke("set_terminal_config", {
project: s.name, project: s.id,
theme: s.theme === "mocha" ? null : s.theme, theme: s.theme === "mocha" ? null : s.theme,
icon: s.icon, icon: s.icon,
title: title === "" ? null : title, 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; settings.value = null;
await refresh(); await refresh();
} catch (e) { } catch (e) {
@@ -371,6 +375,7 @@ async function createProject() {
} }
interface PendingDelete { interface PendingDelete {
id: string;
name: string; name: string;
workDirs: string[]; workDirs: string[];
deleteWorkDirs: boolean; deleteWorkDirs: boolean;
@@ -382,9 +387,9 @@ async function askDelete(p: Project) {
error.value = ""; error.value = "";
try { try {
const workDirs = await invoke<string[]>("project_work_dirs", { 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) { } catch (e) {
error.value = String(e); error.value = String(e);
} }
@@ -394,7 +399,7 @@ async function confirmDelete() {
const d = pendingDelete.value!; const d = pendingDelete.value!;
try { try {
await invoke("delete_project", { await invoke("delete_project", {
name: d.name, project: d.id,
deleteWorkDirs: d.deleteWorkDirs, deleteWorkDirs: d.deleteWorkDirs,
}); });
pendingDelete.value = null; pendingDelete.value = null;
@@ -409,7 +414,7 @@ async function confirmDelete() {
async function unlinkProject() { async function unlinkProject() {
const d = pendingDelete.value!; const d = pendingDelete.value!;
try { try {
await invoke("remove_project", { name: d.name }); await invoke("remove_project", { project: d.id });
pendingDelete.value = null; pendingDelete.value = null;
await refresh(); await refresh();
} catch (e) { } catch (e) {
@@ -453,7 +458,7 @@ onUnmounted(() => window.clearInterval(timer));
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="p in projects" :key="p.name"> <tr v-for="p in projects" :key="p.id">
<td class="cell-dot"> <td class="cell-dot">
<span class="dot" :class="{ on: p.running }"></span> <span class="dot" :class="{ on: p.running }"></span>
</td> </td>
+3 -2
View File
@@ -69,12 +69,13 @@ document.querySelector("header")!.addEventListener("dblclick", (e) => {
// bis die Webview lädt). // bis die Webview lädt).
interface Project { interface Project {
id: string;
name: string; name: string;
pool: string | null; pool: string | null;
terminal: { theme: string | null; icon: string | null; title: string | null }; terminal: { theme: string | null; icon: string | null; title: string | null };
} }
const projects = await invoke<Project[]>("list_projects"); 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) { if (cfg?.pool) {
// Anzeigename statt Pool-ID (Ordnername). // Anzeigename statt Pool-ID (Ordnername).
document.getElementById("pool")!.textContent = await invoke<string>( document.getElementById("pool")!.textContent = await invoke<string>(
@@ -83,7 +84,7 @@ if (cfg?.pool) {
); );
} }
document.getElementById("project-name")!.textContent = 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. // Projekt-Icon vor den Titel — dieselbe data-URL wie in der Projektliste.
if (cfg?.terminal.icon) { if (cfg?.terminal.icon) {