Icons zentral unter ~/.config/ai-control/icons (pool-/repo-unabhängig), nicht ladbares Icon crasht den Tray nicht mehr; Arbeitsordner nachträglich im ⚙︎-Dialog erfassbar (additionalDirectories + Edit-Permission); Import-Button umbenannt; claudeCommand konfigurierbar + CLAUDE_CONFIG_DIR aus Pool; Pool-Tabellen-Layout; README englisch mit Screenshots; MIT-Lizenz
This commit is contained in:
+177
-34
@@ -48,6 +48,12 @@ impl Paths {
|
||||
self.config_dir().join("pools")
|
||||
}
|
||||
|
||||
/// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
|
||||
/// unabhängig von Pool-Zuordnung und Quell-Repos.
|
||||
fn icons_dir(&self) -> PathBuf {
|
||||
self.config_dir().join("icons")
|
||||
}
|
||||
|
||||
fn pool_dir(&self, pool: &str) -> PathBuf {
|
||||
self.pools_dir().join(pool)
|
||||
}
|
||||
@@ -208,6 +214,22 @@ fn sync_on_session_end(paths: &Paths) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
|
||||
pub(crate) fn claude_command(paths: &Paths) -> String {
|
||||
fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "claude".into())
|
||||
}
|
||||
|
||||
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
|
||||
/// CLAUDE_CONFIG_DIR mitgegeben.
|
||||
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
|
||||
let paths = Paths::real();
|
||||
Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
|
||||
}
|
||||
|
||||
/// Setzt das Opt-in; erhält übrige App-settings.
|
||||
fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {
|
||||
let path = paths.config_dir().join(APP_SETTINGS_FILE);
|
||||
@@ -563,6 +585,76 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
register_project(paths, name, &dir)
|
||||
}
|
||||
|
||||
/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
|
||||
/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
|
||||
/// Datei an, wenn sie fehlt (importierte Projekte).
|
||||
fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let wd_path = expand_home(paths, dir);
|
||||
if !wd_path.is_dir() {
|
||||
return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
|
||||
}
|
||||
let dir = contract_home(paths, &wd_path);
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
let mut v: serde_json::Value = if sp.is_file() {
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
|
||||
let perms = root
|
||||
.entry("permissions")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("permissions ist kein Objekt")?;
|
||||
let dirs = perms
|
||||
.entry("additionalDirectories")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("additionalDirectories ist kein Array")?;
|
||||
if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
|
||||
return Err(format!("schon eingetragen: {dir}"));
|
||||
}
|
||||
dirs.push(serde_json::json!(dir));
|
||||
let allow = perms
|
||||
.entry("allow")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("allow ist kein Array")?;
|
||||
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
|
||||
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
|
||||
/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
|
||||
fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let perms = v["permissions"]
|
||||
.as_object_mut()
|
||||
.ok_or("permissions ist kein Objekt")?;
|
||||
let dirs = perms["additionalDirectories"]
|
||||
.as_array_mut()
|
||||
.ok_or("additionalDirectories ist kein Array")?;
|
||||
let before = dirs.len();
|
||||
dirs.retain(|d| d.as_str() != Some(dir));
|
||||
if dirs.len() == before {
|
||||
return Err(format!("nicht eingetragen: {dir}"));
|
||||
}
|
||||
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
|
||||
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
|
||||
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
|
||||
check_name(name)?;
|
||||
@@ -648,8 +740,9 @@ fn set_terminal_config_in(
|
||||
project: &str,
|
||||
mut terminal: TerminalConfig,
|
||||
) -> Result<(), String> {
|
||||
// Absolut gewählte Icons in den Projektordner kopieren und relativ
|
||||
// speichern — so wird das Icon mit dem Projekt-Repo gesynct.
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
// Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
|
||||
// Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
|
||||
if let Some(icon) = terminal.icon.as_deref() {
|
||||
if icon.starts_with('/') {
|
||||
let src = PathBuf::from(icon);
|
||||
@@ -658,25 +751,27 @@ fn set_terminal_config_in(
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("png")
|
||||
.to_lowercase();
|
||||
let name = format!("icon.{ext}");
|
||||
let dest = project_dir(paths, project)?.join(&name);
|
||||
let name = format!("{project}.{ext}");
|
||||
let icons = paths.icons_dir();
|
||||
fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?;
|
||||
let dest = icons.join(&name);
|
||||
if src != dest {
|
||||
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
}
|
||||
terminal.icon = Some(name);
|
||||
}
|
||||
}
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
cfg.terminal = terminal;
|
||||
write_project_config_in(paths, project, &cfg)
|
||||
}
|
||||
|
||||
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
|
||||
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> Result<PathBuf, String> {
|
||||
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
|
||||
/// gemeinsamen Icons-Verzeichnis der App.
|
||||
fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
|
||||
if icon.starts_with('/') {
|
||||
Ok(PathBuf::from(icon))
|
||||
PathBuf::from(icon)
|
||||
} else {
|
||||
Ok(project_dir(paths, project)?.join(icon))
|
||||
paths.icons_dir().join(icon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +784,7 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
|
||||
let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = resolve_icon_path(&paths, &project, &icon)?;
|
||||
let path = resolve_icon_path(&paths, &icon);
|
||||
let is_icns = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
@@ -742,34 +837,49 @@ fn menu_icon(
|
||||
}
|
||||
}
|
||||
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
|
||||
let src = resolve_icon_path(paths, project, &icon)?;
|
||||
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
|
||||
let out = Command::new("sips")
|
||||
.args(["-s", "format", "png", "-z", "36", "36"])
|
||||
.arg(&src)
|
||||
.arg("--out")
|
||||
.arg(&tmp)
|
||||
.output()
|
||||
.map_err(|e| format!("sips: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||
let _ = fs::remove_file(&tmp);
|
||||
let img = tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
let (iw, ih) = (img.width() as usize, img.height() as usize);
|
||||
let rgba = img.rgba();
|
||||
for y in 0..ih.min(H) {
|
||||
for x in 0..iw.min(H) {
|
||||
let src_i = (y * iw + x) * 4;
|
||||
let dst_i = (y * W + DOT_W + x) * 4;
|
||||
canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
|
||||
// Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon):
|
||||
// Meldung, Eintrag erscheint ohne Projekt-Icon.
|
||||
match project_icon_rgba_36(paths, project, &icon) {
|
||||
Ok(img) => {
|
||||
let (iw, ih) = (img.width() as usize, img.height() as usize);
|
||||
let rgba = img.rgba();
|
||||
for y in 0..ih.min(H) {
|
||||
for x in 0..iw.min(H) {
|
||||
let src_i = (y * iw + x) * 4;
|
||||
let dst_i = (y * W + DOT_W + x) * 4;
|
||||
canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Tray-Icon {project}: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
|
||||
}
|
||||
|
||||
/// Projekt-Icon als 36×36-RGBA (sips konvertiert, auch ICNS).
|
||||
fn project_icon_rgba_36(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
icon: &str,
|
||||
) -> Result<tauri::image::Image<'static>, String> {
|
||||
let src = resolve_icon_path(paths, icon);
|
||||
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
|
||||
let out = Command::new("sips")
|
||||
.args(["-s", "format", "png", "-z", "36", "36"])
|
||||
.arg(&src)
|
||||
.arg("--out")
|
||||
.arg(&tmp)
|
||||
.output()
|
||||
.map_err(|e| format!("sips: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||
let _ = fs::remove_file(&tmp);
|
||||
tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
|
||||
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
|
||||
Ok(read_project_config_in(&Paths::real(), project)?.terminal)
|
||||
@@ -1434,6 +1544,16 @@ fn remove_project(name: String) -> Result<(), String> {
|
||||
unregister_project(&Paths::real(), &name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn add_work_dir(project: String, dir: String) -> Result<(), String> {
|
||||
add_work_dir_in(&Paths::real(), &project, &dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_work_dir(project: String, dir: String) -> Result<(), String> {
|
||||
remove_work_dir_in(&Paths::real(), &project, &dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn todo_state(project: String) -> Result<bool, String> {
|
||||
todo_state_in(&Paths::real(), &project)
|
||||
@@ -1645,8 +1765,7 @@ pub fn run() {
|
||||
.expect("Projekt-Config nicht lesbar")
|
||||
.icon
|
||||
.map(|i| {
|
||||
resolve_icon_path(&Paths::real(), &project, &i)
|
||||
.expect("Projekt nicht registriert")
|
||||
resolve_icon_path(&Paths::real(), &i)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
@@ -1787,6 +1906,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||
remove_project,
|
||||
delete_project,
|
||||
project_work_dirs,
|
||||
add_work_dir,
|
||||
remove_work_dir,
|
||||
list_pools,
|
||||
create_oauth_pool,
|
||||
create_apikey_pool,
|
||||
@@ -2610,6 +2731,28 @@ mod tests {
|
||||
assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
|
||||
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
|
||||
add_work_dir_in(&p, "proj", &picked).unwrap();
|
||||
// gespeichert wird ~-kontrahiert, in beiden permissions-Feldern
|
||||
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]);
|
||||
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
|
||||
assert!(raw.contains("Edit(~/projects/extra/**)"));
|
||||
assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt
|
||||
|
||||
remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap();
|
||||
assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty());
|
||||
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
|
||||
assert!(!raw.contains("~/projects/extra"));
|
||||
// Ordner selbst bleibt
|
||||
assert!(p.home.join("projects/extra").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_loeschen_unbekannt_scheitert() {
|
||||
let p = tmp_paths();
|
||||
|
||||
Reference in New Issue
Block a user