From 20fc8474d255e151d5f8c63eeab8385a0d1f8205 Mon Sep 17 00:00:00 2001 From: "marcus.hinz" Date: Sat, 4 Jul 2026 17:21:47 +0200 Subject: [PATCH] =?UTF-8?q?Pools=20initialisieren,=20Pool-Guard=20raus,=20?= =?UTF-8?q?L=C3=B6schschutz=20bei=20laufenden=20Sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pool-guard-Hook aus Projekt-Scaffold und allen bestehenden Projekten entfernt - neue Pools: settings.json mit aufgeräumten UI-Defaults (promptSuggestionEnabled/awaySummaryEnabled aus) + CLAUDE.md - delete_pool bricht bei laufendem zugeordnetem Projekt ab; Übersicht sperrt den Delete-Button und zeigt den Grund per Hover-Pop (WKWebView-tauglich) - Running-Erkennung zentralisiert (running_projects_using_pool), list_pools liefert pro Pool die laufenden Projekte - Todo-Umschalter im Projekt-Wizard/Settings - ungenutzte Dependencies ureq/getrandom entfernt --- src-tauri/Cargo.lock | 114 ------- src-tauri/Cargo.toml | 2 - src-tauri/src/lib.rs | 574 ++++++++++++++++----------------- src/components/PoolList.vue | 26 +- src/components/ProjectList.vue | 39 ++- src/i18n.ts | 44 +-- src/style.css | 36 ++- 7 files changed, 381 insertions(+), 454 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 29ecc91..72cbdd1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -33,7 +33,6 @@ name = "ai-control" version = "0.1.0" dependencies = [ "base64 0.22.1", - "getrandom 0.2.17", "log", "objc2", "objc2-app-kit", @@ -47,7 +46,6 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-log", - "ureq", ] [[package]] @@ -2828,20 +2826,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rkyv" version = "0.7.46" @@ -2903,41 +2887,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.23.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -3371,12 +3320,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swift-rs" version = "1.0.7" @@ -4225,30 +4168,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" @@ -4510,24 +4429,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.8", -] - -[[package]] -name = "webpki-roots" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webview2-com" version = "0.38.2" @@ -4758,15 +4659,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -5191,12 +5083,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4864b00..26be606 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,10 +25,8 @@ tauri = { version = "2.11.3", features = ["tray-icon", "image-png"] } tauri-plugin-log = "2" tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" -ureq = { version = "2", features = ["json"] } base64 = "0.22" sha2 = "0.10" -getrandom = "0.2" portable-pty = "0.9" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 179c3df..4e705f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,25 +1,18 @@ mod terminal; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fs; -use std::io::{BufRead, BufReader, Write}; -use std::net::TcpListener; +use std::io::{BufRead, BufReader}; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const AUTH_URL: &str = "https://claude.ai/oauth/authorize"; -const TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token"; -const SCOPES: &str = "org:create_api_key user:profile user:inference"; - /// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das -/// CLAUDE_CONFIG_DIR; claude liest die .credentials.json dort direkt. -const CREDENTIALS_FILE: &str = ".credentials.json"; +/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain); +/// die App speichert keine Tokens. const APIKEY_FILE: &str = "apikey"; const POOL_FILE: &str = "pool.json"; const PROJECT_FILE: &str = "ai-control.json"; @@ -101,6 +94,15 @@ fn is_running(project: &str) -> bool { ghostty_running(project) || !terminal_pids(project).is_empty() } +/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe +/// Lauf-Erkennung wie die Projektliste (`is_running`). +fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result, String> { + Ok(projects_using_pool(paths, pool)? + .into_iter() + .filter(|p| is_running(p)) + .collect()) +} + fn ghostty_running(project: &str) -> bool { let out = Command::new("lsappinfo") .args(["find", &format!("bundleid={}", bundle_id(project))]) @@ -164,6 +166,101 @@ fn list_projects_in(paths: &Paths) -> Result, String> { Ok(projects) } +// ---------- Feature: Todoliste ---------- + +/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq) +/// als additionalContext in jede Session injiziert. +const TODO_FILE: &str = "OFFENE-PUNKTE.md"; +const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n"; + +fn todo_hook_command(paths: &Paths, name: &str) -> String { + format!( + "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}", + paths.projects_dir().join(name).join(TODO_FILE).display() + ) +} + +fn settings_path(paths: &Paths, name: &str) -> PathBuf { + paths + .projects_dir() + .join(name) + .join(".claude") + .join("settings.json") +} + +fn hook_is_todo(group: &serde_json::Value) -> bool { + group["hooks"] + .as_array() + .map(|hs| { + hs.iter().any(|h| { + h["command"] + .as_str() + .map_or(false, |c| c.contains(TODO_FILE)) + }) + }) + .unwrap_or(false) +} + +fn todo_state_in(paths: &Paths, name: &str) -> Result { + check_name(name)?; + let sp = settings_path(paths, name); + if !sp.is_file() { + return Ok(false); + } + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + let v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; + Ok( + v["hooks"]["SessionStart"] + .as_array() + .map(|a| a.iter().any(hook_is_todo)) + .unwrap_or(false), + ) +} + +fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> { + check_name(name)?; + let sp = settings_path(paths, name); + if !sp.is_file() { + return Err(format!("settings.json fehlt: {}", sp.display())); + } + 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 root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; + let hooks = root + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("hooks ist kein Objekt")?; + let session_start = hooks + .entry("SessionStart") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("SessionStart ist kein Array")?; + + session_start.retain(|g| !hook_is_todo(g)); + if enabled { + session_start.insert( + 0, + serde_json::json!({ + "hooks": [ + { "type": "command", "command": todo_hook_command(paths, name) } + ] + }), + ); + let todo_path = paths.projects_dir().join(name).join(TODO_FILE); + if !todo_path.is_file() { + fs::write(&todo_path, TODO_SKELETON) + .map_err(|e| format!("{}: {e}", todo_path.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())) +} + /// "~/x" relativ zum Home auflösen; alles andere unverändert. fn expand_home(paths: &Paths, p: &str) -> PathBuf { match p.strip_prefix("~/") { @@ -174,7 +271,7 @@ fn expand_home(paths: &Paths, p: &str) -> PathBuf { /// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore /// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, -/// pool-guard-Hook), ai-control.json mit Pool/Terminal-Config. Kein git init — +/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init — /// der Ordner ist Teil des einen claude-projects-Repos. fn create_project_full_in( paths: &Paths, @@ -183,6 +280,7 @@ fn create_project_full_in( work_dir: Option<&str>, create_work_dir: bool, terminal: TerminalConfig, + todo: bool, ) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); @@ -215,26 +313,12 @@ fn create_project_full_in( allow.insert(0, format!("Edit({wd}/**)")); additional.push(wd.to_string()); } - let pool_guard = paths - .projects_dir() - .join("robotunits") - .join("bin") - .join("pool-guard"); let settings = serde_json::json!({ "autoMemoryDirectory": format!("~/claude-projects/{name}/memory"), "permissions": { "allow": allow, "additionalDirectories": additional, }, - "hooks": { - "SessionStart": [ - { - "hooks": [ - { "type": "command", "command": pool_guard.to_string_lossy() } - ] - } - ] - } }); let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?; @@ -243,9 +327,13 @@ fn create_project_full_in( if cfg.pool.is_some() || !cfg.terminal.is_empty() { write_project_config_in(paths, name, &cfg)?; } + if todo { + set_todo_in(paths, name, true)?; + } Ok(()) } +#[cfg(test)] fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); @@ -381,6 +469,9 @@ struct PoolInfo { #[serde(rename = "credentialType")] credential_type: String, projects: Vec, + /// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die + /// Projektliste). Der Löschen-Dialog sperrt darauf. + running: Vec, #[serde(rename = "hasCredentials")] has_credentials: bool, } @@ -405,8 +496,11 @@ fn list_pools_in(paths: &Paths) -> Result, String> { .unwrap_or(false), _ => true, }; + let projects = projects_using_pool(paths, &pool.name)?; + let running = projects.iter().filter(|p| is_running(p)).cloned().collect(); pools.push(PoolInfo { - projects: projects_using_pool(paths, &pool.name)?, + projects, + running, name: pool.name, credential_type: pool.credential_type, has_credentials, @@ -443,8 +537,37 @@ fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> { fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string()) } +/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json +/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als +/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt. +/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst +/// erscheint oben die Vorschlagstabelle. +fn init_pool_config( + dir: &PathBuf, + extra: serde_json::Value, +) -> Result<(), String> { + let mut settings = serde_json::json!({ + "promptSuggestionEnabled": false, + "awaySummaryEnabled": false, + }); + let base = settings.as_object_mut().unwrap(); + if let Some(obj) = extra.as_object() { + for (k, v) in obj { + base.insert(k.clone(), v.clone()); + } + } + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; + fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?; + let claude_md = dir.join("CLAUDE.md"); + if !claude_md.is_file() { + fs::write(&claude_md, "").map_err(|e| e.to_string())?; + } + Ok(()) +} + /// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit -/// apiKeyHelper auf diese Datei, pool.json. +/// apiKeyHelper auf diese Datei, CLAUDE.md, pool.json. fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> { let dir = check_new_pool(paths, name)?; let key = key.trim(); @@ -453,29 +576,25 @@ fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), Str } let key_path = dir.join(APIKEY_FILE); write_secret_file(&key_path, &format!("{key}\n"))?; - let settings = serde_json::json!({ - "apiKeyHelper": format!("cat '{}'", key_path.display()), - }); - let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; - fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?; + init_pool_config( + &dir, + serde_json::json!({ "apiKeyHelper": format!("cat '{}'", key_path.display()) }), + )?; write_pool_json(&dir, name, "apikey") } -/// Legt einen oauth-Pool an: erst Login (schreibt .credentials.json in den -/// Pool-Ordner), dann – nur bei Erfolg – pool.json. -fn create_oauth_pool_in( - paths: &Paths, - name: &str, - login: impl FnOnce(&PathBuf) -> Result<(), String>, -) -> Result<(), String> { +/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md) +/// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools +/// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens. +fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<(), String> { let dir = check_new_pool(paths, name)?; - login(&dir.join(CREDENTIALS_FILE))?; + init_pool_config(&dir, serde_json::json!({}))?; write_pool_json(&dir, name, "oauth") } -/// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt. -/// Löscht den Pool; Projekte, die ihn zugeordnet hatten, verlieren die -/// Zuordnung (Terminal-Einstellungen bleiben erhalten). +/// Löscht einen Pool samt Ordner (inkl. Credentials). Zugeordnete Projekte +/// verlieren die Zuordnung (Terminal-Einstellungen bleiben erhalten). Den +/// Schutz gegen laufende Sessions setzt der delete_pool-Command davor. fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> { let dir = paths.pool_dir(name); if !dir.join(POOL_FILE).is_file() { @@ -500,12 +619,12 @@ fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> { write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n")) } -fn oauth_credential_path(paths: &Paths, pool: &str) -> Result { +fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> { let p = read_pool(paths, pool)?; if p.credential_type != "oauth" { return Err(format!("Pool {pool} ist kein oauth-Pool")); } - Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE)) + Ok(()) } // ---------- Verbrauch ---------- @@ -707,6 +826,7 @@ fn create_project_full( work_dir: Option, create_work_dir: bool, terminal: TerminalConfig, + todo: bool, ) -> Result<(), String> { create_project_full_in( &Paths::real(), @@ -715,9 +835,20 @@ fn create_project_full( work_dir.as_deref(), create_work_dir, terminal, + todo, ) } +#[tauri::command] +fn todo_state(project: String) -> Result { + todo_state_in(&Paths::real(), &project) +} + +#[tauri::command] +fn set_todo(project: String, enabled: bool) -> Result<(), String> { + set_todo_in(&Paths::real(), &project, enabled) +} + #[tauri::command] fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> { if is_running(&name) { @@ -738,7 +869,7 @@ fn list_pools() -> Result, String> { #[tauri::command] fn create_oauth_pool(name: String) -> Result<(), String> { - create_oauth_pool_in(&Paths::real(), &name, run_oauth_flow) + create_oauth_pool_in(&Paths::real(), &name) } #[tauri::command] @@ -748,7 +879,15 @@ fn create_apikey_pool(name: String, key: String) -> Result<(), String> { #[tauri::command] fn delete_pool(name: String) -> Result<(), String> { - delete_pool_in(&Paths::real(), &name) + let paths = Paths::real(); + let running = running_projects_using_pool(&paths, &name)?; + if !running.is_empty() { + return Err(format!( + "Pool wird noch benutzt — läuft: {}", + running.join(", ") + )); + } + delete_pool_in(&paths, &name) } #[tauri::command] @@ -803,19 +942,15 @@ fn keychain_status(pool: String) -> Result { keychain_entry_exists(&keychain_service(&Paths::real(), &pool)) } -/// Meldet einen oauth-Pool neu an: nur bei ungenutztem Pool. Löscht vorher -/// den suffixierten Keychain-Eintrag, damit der neue Login beim nächsten -/// Session-Start auch greift (claude importiert die frische -/// .credentials.json und legt den Eintrag neu an). +/// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag, +/// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur +/// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier. #[tauri::command] fn oauth_login(pool: String) -> Result<(), String> { let paths = Paths::real(); - let cred_path = oauth_credential_path(&paths, &pool)?; + ensure_oauth_pool(&paths, &pool)?; - let running: Vec = projects_using_pool(&paths, &pool)? - .into_iter() - .filter(|p| is_running(p)) - .collect(); + let running = running_projects_using_pool(&paths, &pool)?; if !running.is_empty() { return Err(format!( "Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}", @@ -833,7 +968,7 @@ fn oauth_login(pool: String) -> Result<(), String> { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } } - run_oauth_flow(&cred_path) + Ok(()) } fn open_project(project: &str) -> Result<(), String> { @@ -908,205 +1043,6 @@ fn restart_project(project: String) -> Result<(), String> { open_project(&project) } -// ---------- OAuth-Flow ---------- - -fn pct(s: &str) -> String { - let mut out = String::new(); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => out.push_str(&format!("%{:02X}", b)), - } - } - out -} - -fn random_urlsafe(len: usize) -> String { - let mut buf = vec![0u8; len]; - getrandom::getrandom(&mut buf).expect("getrandom"); - URL_SAFE_NO_PAD.encode(buf) -} - -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Systemzeit") - .as_millis() as u64 -} - -/// Schreibt die Credential-Datei im Claude-Code-Format (0600). -fn write_credentials_file( - cred_path: &PathBuf, - creds: &serde_json::Value, -) -> Result<(), String> { - let raw = serde_json::to_string_pretty(creds).map_err(|e| e.to_string())?; - write_secret_file(cred_path, &(raw + "\n")) -} - -/// Tauscht Code/Refresh-Token gegen Tokens und schreibt die Credential-Datei. -fn exchange_and_store( - body: serde_json::Value, - cred_path: &PathBuf, -) -> Result<(), String> { - let resp = ureq::post(TOKEN_URL) - .set("Content-Type", "application/json") - .send_json(body); - let resp = match resp { - Ok(r) => r, - Err(ureq::Error::Status(code, r)) => { - let txt = r.into_string().unwrap_or_default(); - return Err(format!("Token-Endpoint {code}: {txt}")); - } - Err(e) => return Err(e.to_string()), - }; - let v: serde_json::Value = resp.into_json().map_err(|e| e.to_string())?; - - let access = v["access_token"] - .as_str() - .ok_or("access_token fehlt in Antwort")?; - let refresh = v["refresh_token"] - .as_str() - .ok_or("refresh_token fehlt in Antwort")?; - let expires_in = v["expires_in"].as_u64().unwrap_or(0); - let scopes: Vec = v["scope"] - .as_str() - .unwrap_or(SCOPES) - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - - let creds = serde_json::json!({ - "claudeAiOauth": { - "accessToken": access, - "refreshToken": refresh, - "expiresAt": now_ms() + expires_in * 1000, - "scopes": scopes, - } - }); - write_credentials_file(cred_path, &creds) -} - -/// Startet den Authorization-Code-Flow mit PKCE, öffnet den Browser und -/// nimmt den Redirect auf einem lokalen Loopback-Port entgegen; schreibt -/// die Tokens nach `cred_path`. -fn run_oauth_flow(cred_path: &PathBuf) -> Result<(), String> { - let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; - listener.set_nonblocking(true).map_err(|e| e.to_string())?; - let port = listener.local_addr().map_err(|e| e.to_string())?.port(); - let redirect_uri = format!("http://localhost:{port}/callback"); - - let verifier = random_urlsafe(32); - let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); - let state = random_urlsafe(32); - - let auth_url = format!( - "{AUTH_URL}?code=true&client_id={CLIENT_ID}&response_type=code\ - &redirect_uri={ru}&scope={sc}&code_challenge={challenge}\ - &code_challenge_method=S256&state={st}", - ru = pct(&redirect_uri), - sc = pct(SCOPES), - st = pct(&state), - ); - - Command::new("open") - .arg(&auth_url) - .status() - .map_err(|e| format!("Browser öffnen: {e}"))?; - - let (code, got_state) = wait_for_callback(&listener)?; - if got_state != state { - return Err("state stimmt nicht überein".into()); - } - - let body = serde_json::json!({ - "grant_type": "authorization_code", - "code": code, - "state": got_state, - "client_id": CLIENT_ID, - "redirect_uri": redirect_uri, - "code_verifier": verifier, - }); - exchange_and_store(body, cred_path) -} - -/// Pollt den Loopback-Listener bis zum Redirect mit `code`/`state`. -fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> { - let deadline = Instant::now() + Duration::from_secs(180); - loop { - if Instant::now() > deadline { - return Err("Login-Timeout (kein Redirect nach 180 s)".into()); - } - match listener.accept() { - Ok((mut stream, _)) => { - let mut reader = BufReader::new(&stream); - let mut line = String::new(); - reader.read_line(&mut line).map_err(|e| e.to_string())?; - // "GET /callback?code=...&state=... HTTP/1.1" - let target = line.split_whitespace().nth(1).unwrap_or(""); - let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); - let mut code = String::new(); - let mut state = String::new(); - for pair in query.split('&') { - if let Some(v) = pair.strip_prefix("code=") { - code = pct_decode(v); - } else if let Some(v) = pair.strip_prefix("state=") { - state = pct_decode(v); - } - } - let ok = !code.is_empty(); - let body = if ok { - "Anmeldung erfolgreich. Fenster kann geschlossen werden." - } else { - "Kein Code im Redirect." - }; - let _ = stream.write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .as_bytes(), - ); - if ok { - return Ok((code, state)); - } - // Nebenanfrage (z. B. favicon) ignorieren und weiter warten. - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(100)); - } - Err(e) => return Err(e.to_string()), - } - } -} - -fn pct_decode(s: &str) -> String { - let bytes = s.as_bytes(); - let mut out = Vec::new(); - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'%' if i + 2 < bytes.len() => { - let h = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%'); - out.push(h); - i += 3; - } - b'+' => { - out.push(b' '); - i += 1; - } - c => { - out.push(c); - i += 1; - } - } - } - String::from_utf8_lossy(&out).into_owned() -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST). @@ -1214,6 +1150,8 @@ fn main_builder() -> tauri::Builder { assign_pool, unassign_pool, set_terminal_config, + todo_state, + set_todo, usage_stats, start_project, stop_project, @@ -1272,22 +1210,8 @@ mod tests { Paths { home: dir } } - fn dummy_creds() -> serde_json::Value { - serde_json::json!({ - "claudeAiOauth": { - "accessToken": "at-test", - "refreshToken": "rt-test", - "expiresAt": 1_999_999_999_000u64, - "scopes": ["user:inference"], - } - }) - } - fn make_oauth_pool(p: &Paths, name: &str) { - create_oauth_pool_in(p, name, |cred| { - write_credentials_file(cred, &dummy_creds()) - }) - .unwrap(); + create_oauth_pool_in(p, name).unwrap(); } fn mode(path: &PathBuf) -> u32 { @@ -1330,19 +1254,31 @@ mod tests { serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap(); assert_eq!(pool.credential_type, "oauth"); - let cred_path = dir.join(CREDENTIALS_FILE); - let creds: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap(); - assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-test"); - assert_eq!(mode(&cred_path), 0o600); + // Kein Credentials-File — den Login macht claude selbst beim ersten Start. + assert!(!dir.join(".credentials.json").exists()); + // Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md, + // damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt. + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) + .unwrap(); + assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false)); + assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false)); + assert!(dir.join("CLAUDE.md").is_file()); } #[test] - fn oauth_pool_login_fehlschlag_legt_nichts_an() { + fn pool_init_erhaelt_bestehende_claude_md() { let p = tmp_paths(); - let err = create_oauth_pool_in(&p, "privat", |_| Err("Login abgebrochen".into())); - assert!(err.is_err()); - assert!(!p.pool_dir("privat").exists()); + let dir = p.pool_dir("privat"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap(); + // Anlegen darf die vorhandene CLAUDE.md nicht überschreiben. + // (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen) + init_pool_config(&dir, serde_json::json!({})).unwrap(); + assert_eq!( + fs::read_to_string(dir.join("CLAUDE.md")).unwrap(), + "meine Vorgaben\n" + ); } #[test] @@ -1385,23 +1321,12 @@ mod tests { } #[test] - fn oauth_relogin_ueberschreibt_credentials() { - let p = tmp_paths(); - make_oauth_pool(&p, "privat"); - let cred_path = oauth_credential_path(&p, "privat").unwrap(); - let mut neu = dummy_creds(); - neu["claudeAiOauth"]["accessToken"] = "at-neu".into(); - write_credentials_file(&cred_path, &neu).unwrap(); - let creds: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap(); - assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-neu"); - } - - #[test] - fn oauth_credential_path_auf_apikey_pool_scheitert() { + fn ensure_oauth_pool_auf_apikey_scheitert() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); - assert!(oauth_credential_path(&p, "kunde").is_err()); + assert!(ensure_oauth_pool(&p, "kunde").is_err()); + make_oauth_pool(&p, "privat"); + assert!(ensure_oauth_pool(&p, "privat").is_ok()); } // -- Pool löschen -- @@ -1431,6 +1356,7 @@ mod tests { icon: None, title: Some("Neu".into()), }, + true, ) .unwrap(); @@ -1458,16 +1384,57 @@ mod tests { "Edit(~/claude-projects/neu/**)" ); assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu"); - assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + // todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da + assert!(dir.join(TODO_FILE).is_file()); + let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(groups.len(), 1); + assert!(groups[0]["hooks"][0]["command"] .as_str() .unwrap() - .ends_with("bin/pool-guard")); + .contains(TODO_FILE)); + } + + #[test] + fn todo_zuschalten_und_abschalten() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, false, TerminalConfig::default(), false) + .unwrap(); + assert!(!todo_state_in(&p, "proj").unwrap()); + + set_todo_in(&p, "proj", true).unwrap(); + assert!(todo_state_in(&p, "proj").unwrap()); + let todo_path = p.projects_dir().join("proj").join(TODO_FILE); + assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON); + + // doppelt aktivieren erzeugt keinen zweiten Hook + set_todo_in(&p, "proj", true).unwrap(); + let settings: serde_json::Value = serde_json::from_str( + &fs::read_to_string(settings_path(&p, "proj")).unwrap(), + ) + .unwrap(); + let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1); + + // Abschalten: Hook weg, Datei (mit Inhalt) bleibt + fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap(); + set_todo_in(&p, "proj", false).unwrap(); + assert!(!todo_state_in(&p, "proj").unwrap()); + assert!(todo_path.is_file()); + assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig")); + } + + #[test] + fn todo_ohne_settings_scheitert() { + let p = tmp_paths(); + create_project_in(&p, "alt").unwrap(); + assert!(set_todo_in(&p, "alt", true).is_err()); + assert!(!todo_state_in(&p, "alt").unwrap()); } #[test] fn projekt_wizard_minimal_ohne_pool_und_workdir() { let p = tmp_paths(); - create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default()).unwrap(); + create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false).unwrap(); let dir = p.projects_dir().join("neu"); assert!(dir.join(".claude").join("settings.json").is_file()); // ohne Pool und Terminal-Config entsteht keine ai-control.json @@ -1485,7 +1452,7 @@ mod tests { let p = tmp_paths(); create_project_in(&p, "neu").unwrap(); let err = - create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default()) + create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false) .unwrap_err(); assert!(err.contains("existiert bereits")); } @@ -1500,6 +1467,7 @@ mod tests { Some("~/projects/gibtsnicht"), false, TerminalConfig::default(), + false, ) .unwrap_err(); assert!(err.contains("Arbeitsverzeichnis fehlt")); @@ -1737,7 +1705,7 @@ mod tests { #[test] fn projekt_loeschen_laesst_arbeitsordner() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); delete_project_in(&p, "proj", false).unwrap(); assert!(!p.projects_dir().join("proj").exists()); @@ -1747,7 +1715,7 @@ mod tests { #[test] fn projekt_loeschen_mit_arbeitsordner() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); delete_project_in(&p, "proj", true).unwrap(); assert!(!p.projects_dir().join("proj").exists()); @@ -1757,7 +1725,7 @@ mod tests { #[test] fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); assert!(delete_project_in(&p, "proj", true).is_err()); @@ -1766,7 +1734,7 @@ mod tests { #[test] fn projekt_arbeitsordner_auslesen() { let p = tmp_paths(); - create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default()) + create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]); // Projekt ohne settings.json → leer diff --git a/src/components/PoolList.vue b/src/components/PoolList.vue index 4e7bcf0..18033fe 100644 --- a/src/components/PoolList.vue +++ b/src/components/PoolList.vue @@ -9,6 +9,7 @@ interface Pool { name: string; credentialType: string; projects: string[]; + running: string[]; hasCredentials: boolean; } @@ -206,9 +207,20 @@ onMounted(refresh); {{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }} - + + + + + {{ $t("pools.deleteBlockedTooltip", { projects: p.running.join(", ") }) }} + + + @@ -239,16 +251,13 @@ onMounted(refresh);

{{ $t("pools.oauthHint") }}

-

- {{ $t("pools.oauthWaiting") }} -

@@ -280,13 +289,12 @@ onMounted(refresh); : $t("pools.reloginNoEntry") }}

-

{{ $t("pools.oauthWaiting") }}

diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue index 0f14757..73fc298 100644 --- a/src/components/ProjectList.vue +++ b/src/components/ProjectList.vue @@ -110,17 +110,24 @@ interface TerminalSettings { theme: string; icon: string | null; title: string; + todo: boolean; } const settings = ref(null); -function openSettings(p: Project) { - settings.value = { - name: p.name, - theme: p.terminal.theme ?? "mocha", - icon: p.terminal.icon, - title: p.terminal.title ?? "", - }; +async function openSettings(p: Project) { + try { + const todo = await invoke("todo_state", { project: p.name }); + settings.value = { + name: p.name, + theme: p.terminal.theme ?? "mocha", + icon: p.terminal.icon, + title: p.terminal.title ?? "", + todo, + }; + } catch (e) { + error.value = String(e); + } } async function pickIcon() { @@ -142,6 +149,7 @@ async function saveSettings() { icon: s.icon, title: title === "" ? null : title, }); + await invoke("set_todo", { project: s.name, enabled: s.todo }); settings.value = null; await refresh(); } catch (e) { @@ -156,6 +164,7 @@ interface Wizard { workDir: string | null; title: string; theme: string; + todo: boolean; } const wizard = ref(null); @@ -169,6 +178,7 @@ function openWizard() { workDir: null, title: "", theme: "mocha", + todo: false, }; } @@ -201,6 +211,7 @@ async function createProject() { icon: null, title: title === "" ? null : title, }, + todo: w.todo, }); wizard.value = null; await refresh(); @@ -378,6 +389,13 @@ onUnmounted(() => window.clearInterval(timer)); +

{{ $t("projects.wizardHint") }}

diff --git a/src/i18n.ts b/src/i18n.ts index d822e84..8b0b47b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -59,7 +59,7 @@ const de = { chooseFolder: "Ordner wählen …", create: "Anlegen", wizardHint: - "Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json mit pool-guard-Hook und die Pool-/Terminal-Zuordnung.", + "Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json (Memory-Verzeichnis + Berechtigungen) und die Pool-/Terminal-Zuordnung.", delete: "Projekt löschen", deleteRunning: "Läuft noch — erst beenden", deleteTitle: "{name} löschen", @@ -67,6 +67,8 @@ const de = { "Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.", deleteWorkDirs: "Arbeitsordner mitlöschen:", workDirsStay: "Die Arbeitsordner bleiben unangetastet.", + todo: "Todoliste", + todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen", }, pools: { pool: "Pool", @@ -74,7 +76,7 @@ const de = { actions: "Aktionen", newOauth: "+ oAuth", newApikey: "+ apiKey", - relogin: "Neu anmelden", + relogin: "Zurücksetzen", changeKey: "Key ändern", insertKey: "Key eintragen", delete: "Löschen", @@ -86,22 +88,24 @@ const de = { name: "Name", apiKey: "API-Key", oauthHint: - "Nach „Login starten“ öffnet sich der Browser zur Anmeldung gegen dein Abo.", - oauthWaiting: "Warte auf die Anmeldung im Browser…", + "Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).", cancel: "Abbrechen", - startLogin: "Login starten", + createPool: "Anlegen", + reset: "Zurücksetzen", save: "Speichern", deletePool: "Pool löschen", deleteWarning: "Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.", deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:", - reloginTitle: "Neu anmelden – {name}", + deleteBlockedTooltip: + "Löschen gesperrt — {projects} läuft gerade. Erst beenden.", + reloginTitle: "{name} zurücksetzen", reloginWarning: - "Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Danach öffnet sich der Browser zur Neuanmeldung.", + "Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Beim nächsten Start verlangt claude erneut /login.", reloginNoEntry: - "Kein Schlüsselbund-Eintrag vorhanden — es erfolgt nur die Neuanmeldung im Browser.", + "Kein Schlüsselbund-Eintrag vorhanden — beim nächsten Start meldet sich claude ohnehin per /login an.", reloginBlocked: - "Neuanmeldung ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:", + "Zurücksetzen ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:", }, }; @@ -164,7 +168,7 @@ const en: typeof de = { chooseFolder: "Choose folder …", create: "Create", wizardHint: - "Creates the project folder with memory/, sentinel .gitignore, settings.json with the pool-guard hook, and the pool/terminal assignment.", + "Creates the project folder with memory/, sentinel .gitignore, settings.json (memory dir + permissions), and the pool/terminal assignment.", delete: "Delete project", deleteRunning: "Still running — stop it first", deleteTitle: "Delete {name}", @@ -172,6 +176,8 @@ const en: typeof de = { "The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.", deleteWorkDirs: "Also delete working folders:", workDirsStay: "The working folders stay untouched.", + todo: "Todo list", + todoDesc: "Inject OFFENE-PUNKTE.md at every session start", }, pools: { pool: "Pool", @@ -179,7 +185,7 @@ const en: typeof de = { actions: "Actions", newOauth: "+ oAuth", newApikey: "+ apiKey", - relogin: "Sign in again", + relogin: "Reset", changeKey: "Change key", insertKey: "Insert key", delete: "Delete", @@ -191,22 +197,24 @@ const en: typeof de = { name: "Name", apiKey: "API key", oauthHint: - "After “Start login” the browser opens to sign in against your subscription.", - oauthWaiting: "Waiting for the sign-in in the browser…", + "The pool is created; claude signs in itself on first start via /login (browser against your subscription).", cancel: "Cancel", - startLogin: "Start login", + createPool: "Create", + reset: "Reset", save: "Save", deletePool: "Delete pool", deleteWarning: "Pool {name} and its credential file will be deleted. This cannot be undone.", deleteUnassigns: "These projects lose their pool assignment:", - reloginTitle: "Sign in again – {name}", + deleteBlockedTooltip: + "Deletion blocked — active: {projects}. Stop the session first.", + reloginTitle: "Reset {name}", reloginWarning: - "The access token for {name} stored in the keychain will be deleted. The browser then opens for a new sign-in.", + "The access token for {name} stored in the keychain will be deleted. On the next start claude asks for /login again.", reloginNoEntry: - "No keychain entry present — only the new sign-in happens.", + "No keychain entry present — claude signs in via /login on the next start anyway.", reloginBlocked: - "Signing in again requires an unused pool. These projects are still running:", + "Resetting requires an unused pool. These projects are still running:", }, }; diff --git a/src/style.css b/src/style.css index c0766d5..1837f73 100644 --- a/src/style.css +++ b/src/style.css @@ -208,6 +208,35 @@ button.danger:hover:not(:disabled) { width: 9.5rem; } +/* Tooltip-Träger um den Löschen-Button: ein disabled Button empfängt keine + Hover-Events, darum sitzt das Hover-Pop am Span und der Button ist im + gesperrten Zustand für die Maus durchlässig. WKWebView zeigt keine nativen + title-Tooltips, daher das CSS-Pop (hover-pop/pop). */ +.tip-wrap { + display: inline-flex; +} +.tip-wrap button:disabled { + pointer-events: none; +} + +/* Pop rechtsbündig — der Löschen-Button steht am rechten Zeilenrand. + Schmaler als das Standard-Pop, damit der Hinweis auf mind. zwei Zeilen + umbricht statt in einer sehr breiten Zeile zu stehen. */ +.hover-pop .pop.pop-right { + left: auto; + right: 0; + min-width: 0; + width: 13rem; + max-width: 13rem; +} +.pop-text { + font-size: 1rem; + line-height: 1.5; + color: var(--text); + white-space: normal; + overflow-wrap: anywhere; +} + button.gear { display: inline-flex; align-items: center; @@ -584,6 +613,11 @@ td select { cursor: pointer; } +.dialog .field .checkline { + font-size: 0.85rem; + color: var(--subtext); +} + .dialog .affected { margin: 0; padding-left: 1.1rem; @@ -604,6 +638,6 @@ td select { /* ---------- Formulare (Pools-Dialoge) ---------- */ -form.dialog input { +form.dialog input:not([type="checkbox"]) { width: 100%; }