Ghostty raus, Session-Watcher mit Sync-Opt-in, Tooltip WKWebView-tauglich

- Ghostty komplett entfernt: bundle_id/ghostty_running/open_project/start_project
  weg, is_running nur noch über terminal_pids; restart_project über das interne
  Terminal (kill_terminals + open_terminal) statt osascript-Quit
- Session-Watcher im Tray-Prozess: synct das claude-projects-Repo bei
  Session-Ende (Prozess verschwindet, greift auch bei HUP/Kill)
- Sync als Opt-in (~/.config/ai-control/settings.json, default aus) und nur
  wenn ein Git-Repo vorhanden ist; Commands sync_setting/set_sync_setting
- Löschen-Button in der Pool-Übersicht gesperrt bei laufendem Projekt, Grund per
  CSS-Hover-Pop (WKWebView zeigt keine nativen title-Tooltips)
This commit is contained in:
marcus.hinz
2026-07-04 19:09:06 +02:00
parent 20fc8474d2
commit 3e022311ee
2 changed files with 139 additions and 73 deletions
+137 -70
View File
@@ -33,8 +33,12 @@ impl Paths {
self.home.join("claude-projects")
}
fn config_dir(&self) -> PathBuf {
self.home.join(".config").join("ai-control")
}
fn pools_dir(&self) -> PathBuf {
self.home.join(".config").join("ai-control").join("pools")
self.config_dir().join("pools")
}
fn pool_dir(&self, pool: &str) -> PathBuf {
@@ -86,12 +90,8 @@ impl TerminalConfig {
}
}
fn bundle_id(project: &str) -> String {
format!("com.mitchellh.ghostty.{project}")
}
fn is_running(project: &str) -> bool {
ghostty_running(project) || !terminal_pids(project).is_empty()
!terminal_pids(project).is_empty()
}
/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
@@ -103,16 +103,6 @@ fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>,
.collect())
}
fn ghostty_running(project: &str) -> bool {
let out = Command::new("lsappinfo")
.args(["find", &format!("bundleid={}", bundle_id(project))])
.output();
match out {
Ok(o) => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
Err(_) => false,
}
}
/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
fn terminal_pids(project: &str) -> Vec<u32> {
let out = Command::new("pgrep")
@@ -127,6 +117,92 @@ fn terminal_pids(project: &str) -> Vec<u32> {
}
}
/// Tick-Intervall des Session-Watchers.
const WATCH_INTERVAL: Duration = Duration::from_secs(5);
/// App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen).
const APP_SETTINGS_FILE: &str = "settings.json";
/// Opt-in: synct der Watcher bei Session-Ende? Default aus.
fn sync_on_session_end(paths: &Paths) -> bool {
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["syncOnSessionEnd"].as_bool())
.unwrap_or(false)
}
/// 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);
let mut v: serde_json::Value = fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
v["syncOnSessionEnd"] = serde_json::json!(enabled);
fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
}
fn is_git_repo(dir: &PathBuf) -> bool {
dir.join(".git").exists()
}
/// Namen aller Projekte, die gerade laufen — dieselbe Erkennung wie die
/// Projektliste.
fn running_project_set(paths: &Paths) -> HashSet<String> {
list_projects_in(paths)
.map(|ps| ps.into_iter().filter(|p| p.running).map(|p| p.name).collect())
.unwrap_or_default()
}
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
/// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher).
fn sync_session_context(home: &PathBuf, project: &str) {
let git_sync = home.join("claude-projects/pool/bin/git-sync");
let repo = home.join("claude-projects");
match Command::new(&git_sync)
.arg(&repo)
.arg(format!("session-end sync: {project}"))
.output()
{
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"session-end sync {project}: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"),
}
}
/// Session-Watcher im Tray-Prozess: pollt die laufenden Projekte; wechselt eines
/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
fn spawn_session_watcher() {
std::thread::spawn(|| {
let paths = Paths::real();
let mut running = running_project_set(&paths);
loop {
std::thread::sleep(WATCH_INTERVAL);
let current = running_project_set(&paths);
let ended: Vec<&String> = running.difference(&current).collect();
// Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray
// verlangt kein Repo, er nutzt es nur, falls vorhanden.
if !ended.is_empty()
&& sync_on_session_end(&paths)
&& is_git_repo(&paths.projects_dir())
{
for project in ended {
sync_session_context(&paths.home, project);
}
}
running = current;
}
});
}
fn check_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
return Err(format!("ungültiger Name: {name}"));
@@ -971,68 +1047,35 @@ fn oauth_login(pool: String) -> Result<(), String> {
Ok(())
}
fn open_project(project: &str) -> Result<(), String> {
// Läuft die UI selbst in einer Ghostty-App, erbt `open` deren
// XDG_CONFIG_HOME und kalt gestartete Apps läsen die falsche Config.
let out = Command::new("open")
.env_remove("XDG_CONFIG_HOME")
.args(["-b", &bundle_id(project)])
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[tauri::command]
fn start_project(project: String) -> Result<(), String> {
open_project(&project)
}
/// Beendet den laufenden Prozess eines Projekts: das eingebaute Terminal per
/// SIGTERM auf die exakte PID (nie über die Bundle-ID — die würde alle
/// Instanzen inkl. der Haupt-App treffen), sonst die Ghostty-App per Quit.
#[tauri::command]
fn stop_project(project: String) -> Result<(), String> {
let pids = terminal_pids(&project);
if !pids.is_empty() {
for pid in pids {
let out = Command::new("kill")
.arg(pid.to_string())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
}
return Ok(());
}
if ghostty_running(&project) {
let out = Command::new("osascript")
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte
/// PID. Der Prozesstod schließt den PTY-Master, claude bekommt HUP und endet —
/// wie beim Schließen des Fensters.
fn kill_terminals(project: &str) -> Result<(), String> {
for pid in terminal_pids(project) {
let out = Command::new("kill")
.arg(pid.to_string())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
return Ok(());
}
Err(format!("{project} läuft nicht"))
Ok(())
}
/// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch
/// nachfragen), wartet auf das Prozessende und startet sie neu.
#[tauri::command]
fn stop_project(project: String) -> Result<(), String> {
if terminal_pids(&project).is_empty() {
return Err(format!("{project} läuft nicht"));
}
kill_terminals(&project)
}
/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu.
#[tauri::command]
fn restart_project(project: String) -> Result<(), String> {
let out = Command::new("osascript")
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
kill_terminals(&project)?;
let deadline = Instant::now() + Duration::from_secs(30);
while is_running(&project) {
if Instant::now() > deadline {
@@ -1040,7 +1083,17 @@ fn restart_project(project: String) -> Result<(), String> {
}
std::thread::sleep(Duration::from_millis(250));
}
open_project(&project)
terminal::open_terminal(project)
}
#[tauri::command]
fn sync_setting() -> Result<bool, String> {
Ok(sync_on_session_end(&Paths::real()))
}
#[tauri::command]
fn set_sync_setting(enabled: bool) -> Result<(), String> {
set_sync_on_session_end_in(&Paths::real(), enabled)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -1098,6 +1151,9 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
spawn_session_watcher();
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
@@ -1153,9 +1209,10 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
todo_state,
set_todo,
usage_stats,
start_project,
stop_project,
restart_project,
sync_setting,
set_sync_setting,
oauth_login,
keychain_status,
set_apikey,
@@ -1590,6 +1647,16 @@ mod tests {
assert!(!p.project_config("proj").exists());
}
#[test]
fn sync_optin_default_aus_und_umschaltbar() {
let p = tmp_paths();
assert!(!sync_on_session_end(&p)); // default: kein Sync ohne Zustimmung
set_sync_on_session_end_in(&p, true).unwrap();
assert!(sync_on_session_end(&p));
set_sync_on_session_end_in(&p, false).unwrap();
assert!(!sync_on_session_end(&p));
}
#[test]
fn pool_loeschen_erhaelt_terminal_config() {
let p = tmp_paths();
+2 -3
View File
@@ -93,7 +93,7 @@ pub fn build_window(
/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst — identisch zum Ghostty-Starter.
/// claude-sync selbst.
#[tauri::command]
pub fn term_start(
window: tauri::WebviewWindow,
@@ -181,8 +181,7 @@ pub fn term_resize(
}
/// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen.
/// Das Droppen des Masters schließt die PTY — claude bekommt HUP wie beim
/// Fenster-X in Ghostty.
/// Das Droppen des Masters schließt die PTY — claude bekommt HUP.
pub fn close(window: &tauri::Window) {
let terminals = window.state::<Terminals>();
let session = terminals.0.lock().unwrap().remove(window.label());