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:
+135
-68
@@ -33,8 +33,12 @@ impl Paths {
|
|||||||
self.home.join("claude-projects")
|
self.home.join("claude-projects")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn config_dir(&self) -> PathBuf {
|
||||||
|
self.home.join(".config").join("ai-control")
|
||||||
|
}
|
||||||
|
|
||||||
fn pools_dir(&self) -> PathBuf {
|
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 {
|
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 {
|
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
|
/// 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())
|
.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>`).
|
/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
|
||||||
fn terminal_pids(project: &str) -> Vec<u32> {
|
fn terminal_pids(project: &str) -> Vec<u32> {
|
||||||
let out = Command::new("pgrep")
|
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> {
|
fn check_name(name: &str) -> Result<(), String> {
|
||||||
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
|
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
|
||||||
return Err(format!("ungültiger Name: {name}"));
|
return Err(format!("ungültiger Name: {name}"));
|
||||||
@@ -971,34 +1047,11 @@ fn oauth_login(pool: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_project(project: &str) -> Result<(), String> {
|
/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte
|
||||||
// Läuft die UI selbst in einer Ghostty-App, erbt `open` deren
|
/// PID. Der Prozesstod schließt den PTY-Master, claude bekommt HUP und endet —
|
||||||
// XDG_CONFIG_HOME und kalt gestartete Apps läsen die falsche Config.
|
/// wie beim Schließen des Fensters.
|
||||||
let out = Command::new("open")
|
fn kill_terminals(project: &str) -> Result<(), String> {
|
||||||
.env_remove("XDG_CONFIG_HOME")
|
for pid in terminal_pids(project) {
|
||||||
.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")
|
let out = Command::new("kill")
|
||||||
.arg(pid.to_string())
|
.arg(pid.to_string())
|
||||||
.output()
|
.output()
|
||||||
@@ -1007,32 +1060,22 @@ fn stop_project(project: String) -> Result<(), String> {
|
|||||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
|
||||||
if ghostty_running(&project) {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(format!("{project} läuft nicht"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch
|
#[tauri::command]
|
||||||
/// nachfragen), wartet auf das Prozessende und startet sie neu.
|
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]
|
#[tauri::command]
|
||||||
fn restart_project(project: String) -> Result<(), String> {
|
fn restart_project(project: String) -> Result<(), String> {
|
||||||
let out = Command::new("osascript")
|
kill_terminals(&project)?;
|
||||||
.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());
|
|
||||||
}
|
|
||||||
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 {
|
||||||
@@ -1040,7 +1083,17 @@ fn restart_project(project: String) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(250));
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -1098,6 +1151,9 @@ 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);
|
||||||
|
|
||||||
|
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
|
||||||
|
spawn_session_watcher();
|
||||||
|
|
||||||
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
|
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
|
||||||
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
|
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
|
||||||
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
|
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
|
||||||
@@ -1153,9 +1209,10 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
todo_state,
|
todo_state,
|
||||||
set_todo,
|
set_todo,
|
||||||
usage_stats,
|
usage_stats,
|
||||||
start_project,
|
|
||||||
stop_project,
|
stop_project,
|
||||||
restart_project,
|
restart_project,
|
||||||
|
sync_setting,
|
||||||
|
set_sync_setting,
|
||||||
oauth_login,
|
oauth_login,
|
||||||
keychain_status,
|
keychain_status,
|
||||||
set_apikey,
|
set_apikey,
|
||||||
@@ -1590,6 +1647,16 @@ mod tests {
|
|||||||
assert!(!p.project_config("proj").exists());
|
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]
|
#[test]
|
||||||
fn pool_loeschen_erhaelt_terminal_config() {
|
fn pool_loeschen_erhaelt_terminal_config() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ pub fn build_window(
|
|||||||
|
|
||||||
/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
|
/// 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
|
/// 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]
|
#[tauri::command]
|
||||||
pub fn term_start(
|
pub fn term_start(
|
||||||
window: tauri::WebviewWindow,
|
window: tauri::WebviewWindow,
|
||||||
@@ -181,8 +181,7 @@ pub fn term_resize(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen.
|
/// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen.
|
||||||
/// Das Droppen des Masters schließt die PTY — claude bekommt HUP wie beim
|
/// Das Droppen des Masters schließt die PTY — claude bekommt HUP.
|
||||||
/// Fenster-X in Ghostty.
|
|
||||||
pub fn close(window: &tauri::Window) {
|
pub fn close(window: &tauri::Window) {
|
||||||
let terminals = window.state::<Terminals>();
|
let terminals = window.state::<Terminals>();
|
||||||
let session = terminals.0.lock().unwrap().remove(window.label());
|
let session = terminals.0.lock().unwrap().remove(window.label());
|
||||||
|
|||||||
Reference in New Issue
Block a user