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:
+137
-70
@@ -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(¤t).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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user