Skip to main content

xbp_cli/commands/
worktree_watch_tray.rs

1//! System tray UI for worktree-watch (Windows native tray + Linux StatusNotifier / GNOME).
2//!
3//! Uses the same `tray-icon` + `muda` stack as `xbp mcp --tray` so both desktop
4//! environments share one implementation path:
5//! - Windows: NotifyIcon
6//! - Linux/GNOME: StatusNotifierItem (AppIndicator extension on classic GNOME Shell)
7
8use crate::commands::worktree_watch::{
9    collect_worktree_watch_tray_snapshot, run_worktree_watch_start, run_worktree_watch_stop,
10    WorktreeWatchStartOptions, WorktreeWatchStopOptions, WorktreeWatchTargetOptions,
11    WorktreeWatchTraySnapshot,
12};
13use crate::config::ensure_global_xbp_paths;
14use crate::utils::tray_runtime::{load_tray_icon_from_png, pump_tray_events};
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex};
22use std::thread;
23use std::time::Duration;
24use sysinfo::{Pid, System};
25
26const TRAY_ICON_BYTES: &[u8] =
27    include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/xylex-app-icon.png"));
28const TRAY_BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_TRAY_CHILD";
29const TRAY_STATE_FILE_NAME: &str = "worktree-watch-tray.json";
30const STATUS_POLL_MS: u64 = 2_500;
31const MENU_POLL_MS: u64 = 120;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34struct WorktreeWatchTrayInstanceState {
35    pid: u32,
36    executable: String,
37    target_key: String,
38    started_at: DateTime<Utc>,
39}
40
41struct TrayInstanceGuard {
42    state_file: PathBuf,
43}
44
45impl Drop for TrayInstanceGuard {
46    fn drop(&mut self) {
47        let _ = clear_tray_instance_state(&self.state_file);
48    }
49}
50
51#[derive(Debug, Clone)]
52pub struct WorktreeWatchTrayOptions {
53    pub target: WorktreeWatchTargetOptions,
54    pub sync_interval_seconds: u64,
55}
56
57/// Returns true when this process was spawned as the detached tray child.
58pub fn is_tray_background_child() -> bool {
59    std::env::var(TRAY_BACKGROUND_CHILD_ENV)
60        .ok()
61        .is_some_and(|value| value == "1")
62}
63
64/// Detach from any inherited console so background tray children do not spawn new shells.
65pub fn prepare_background_tray_process() {
66    if !is_tray_background_child() {
67        return;
68    }
69
70    #[cfg(windows)]
71    {
72        unsafe {
73            use windows_sys::Win32::System::Console::FreeConsole;
74            let _ = FreeConsole();
75        }
76    }
77}
78
79/// Spawn a detached tray process and return immediately.
80pub fn spawn_detached_worktree_watch_tray(options: WorktreeWatchTrayOptions) -> Result<(), String> {
81    if let Some(existing_pid) = existing_tray_instance_pid() {
82        println!(
83            "Worktree-watch tray already running (pid {existing_pid}). Right-click the tray icon to quit before starting another."
84        );
85        return Ok(());
86    }
87
88    let executable = std::env::current_exe()
89        .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
90
91    let mut command = Command::new(&executable);
92    command
93        .arg("worktree-watch")
94        .arg("tray")
95        .env(TRAY_BACKGROUND_CHILD_ENV, "1")
96        .stdin(Stdio::null())
97        .stdout(Stdio::null())
98        .stderr(Stdio::null());
99
100    append_tray_target_args(&mut command, &options.target)?;
101    command.arg("--sync-interval-seconds");
102    command.arg(options.sync_interval_seconds.to_string());
103    configure_detached_tray_process(&mut command);
104
105    command
106        .spawn()
107        .map_err(|error| format!("Failed to spawn background worktree-watch tray: {error}"))?;
108
109    println!(
110        "XBP worktree-watch tray started in the background ({}) — right-click the tray icon for Start/Stop/Stats.",
111        platform_tray_hint()
112    );
113    println!("Target: {}", describe_target(&Arc::new(options.target)));
114    Ok(())
115}
116
117/// Run the blocking worktree-watch tray event loop until Quit is chosen.
118pub fn run_worktree_watch_tray(options: WorktreeWatchTrayOptions) -> Result<(), String> {
119    prepare_background_tray_process();
120    run_tray_loop(options)
121}
122
123fn run_tray_loop(options: WorktreeWatchTrayOptions) -> Result<(), String> {
124    use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
125    use tray_icon::{Icon, TrayIconBuilder, TrayIconEvent};
126
127    let _instance_guard = acquire_tray_instance_guard(&options.target)?;
128
129    let icon: Icon = load_tray_icon_from_png(TRAY_ICON_BYTES)?;
130
131    let target = Arc::new(options.target.clone());
132    let sync_interval_seconds = options.sync_interval_seconds;
133    let quit_flag = Arc::new(AtomicBool::new(false));
134    let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
135
136    // Local dashboard HTTP API (loopback). Prefer tray as the control plane when present.
137    let api_port = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
138        options.target.clone(),
139        sync_interval_seconds,
140        None,
141    );
142
143    let start_item = MenuItem::new("Start watching", true, None);
144    let stop_item = MenuItem::new("Stop watching", true, None);
145    let refresh_item = MenuItem::new("Refresh status", true, None);
146    let stats_item = MenuItem::new("Show stats (console)", true, None);
147    let status_item = MenuItem::new("Status: loading…", false, None);
148    let target_item = MenuItem::new(&format!("Target: {}", describe_target(&target)), false, None);
149    let last_error_item = MenuItem::new("Last error: none", false, None);
150    let quit_item = MenuItem::new("Quit tray", true, None);
151
152    // Auto-refresh checkbox — keeps tooltip/status updated while open.
153    let auto_refresh = CheckMenuItem::new("Auto-refresh status", true, true, None);
154
155    let repos_submenu = Submenu::new("Repositories", true);
156    let mut repo_menu_items: Vec<MenuItem> = Vec::new();
157
158    let menu = Menu::new();
159    menu.append(&status_item)
160        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
161    menu.append(&target_item)
162        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
163    menu.append(&last_error_item)
164        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
165    menu.append(&PredefinedMenuItem::separator())
166        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
167    menu.append(&start_item)
168        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
169    menu.append(&stop_item)
170        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
171    menu.append(&PredefinedMenuItem::separator())
172        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
173    menu.append(&refresh_item)
174        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
175    menu.append(&auto_refresh)
176        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
177    menu.append(&stats_item)
178        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
179    menu.append(&repos_submenu)
180        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
181    menu.append(&PredefinedMenuItem::separator())
182        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
183    menu.append(&quit_item)
184        .map_err(|error| format!("Failed to build tray menu: {error}"))?;
185
186    let tray = TrayIconBuilder::new()
187        .with_tooltip("XBP worktree-watch")
188        .with_icon(icon)
189        .with_menu(Box::new(menu))
190        .build()
191        .map_err(|error| format!("Failed to create tray icon: {error}"))?;
192
193    let _tray = tray;
194
195    if is_tray_background_child() {
196        tracing::info!(
197            "XBP worktree-watch tray ready ({})",
198            platform_tray_hint()
199        );
200        if let Some(port) = api_port {
201            tracing::info!("worktree-watch local API listening on http://127.0.0.1:{port}");
202        }
203    } else {
204        println!(
205            "XBP worktree-watch tray ready ({}) — right-click the tray icon for Start/Stop/Stats.",
206            platform_tray_hint()
207        );
208        println!("Target: {}", describe_target(&target));
209        if let Some(port) = api_port {
210            crate::commands::worktree_watch_api::print_api_listen_hint(port);
211        }
212        if cfg!(target_os = "linux") {
213            println!(
214                "GNOME Shell note: StatusNotifier/AppIndicator support is required \
215(e.g. AppIndicator extension on classic GNOME)."
216            );
217        }
218    }
219
220    let last_snapshot: Arc<Mutex<Option<WorktreeWatchTraySnapshot>>> = Arc::new(Mutex::new(None));
221
222    // Initial status.
223    if let Ok(snapshot) = collect_worktree_watch_tray_snapshot(&target) {
224        apply_snapshot_to_tray(
225            &_tray,
226            &status_item,
227            &start_item,
228            &stop_item,
229            &last_error_item,
230            &repos_submenu,
231            &mut repo_menu_items,
232            &snapshot,
233            None,
234        );
235        if let Ok(mut guard) = last_snapshot.lock() {
236            *guard = Some(snapshot);
237        }
238    }
239
240    let menu_channel = MenuEvent::receiver();
241    let tray_channel = TrayIconEvent::receiver();
242    let mut last_poll = std::time::Instant::now()
243        .checked_sub(Duration::from_millis(STATUS_POLL_MS))
244        .unwrap_or_else(std::time::Instant::now);
245
246    while !quit_flag.load(Ordering::SeqCst) {
247        pump_tray_events();
248
249        while let Ok(_event) = tray_channel.try_recv() {
250            // Tray click events are handled by the native shell; draining keeps the channel clear.
251        }
252
253        while let Ok(event) = menu_channel.try_recv() {
254            if event.id == quit_item.id() {
255                quit_flag.store(true, Ordering::SeqCst);
256                break;
257            }
258            if event.id == refresh_item.id() || event.id == auto_refresh.id() {
259                // fall through to poll below
260                last_poll = std::time::Instant::now()
261                    .checked_sub(Duration::from_millis(STATUS_POLL_MS))
262                    .unwrap_or_else(std::time::Instant::now);
263            }
264            if event.id == start_item.id() {
265                let target = target.clone();
266                match start_watchers((*target).clone(), sync_interval_seconds) {
267                    Ok(message) => {
268                        record_tray_action(&last_error, None);
269                        if is_tray_background_child() {
270                            tracing::info!("{message}");
271                        } else {
272                            println!("{message}");
273                        }
274                        last_poll = std::time::Instant::now()
275                            .checked_sub(Duration::from_millis(STATUS_POLL_MS))
276                            .unwrap_or_else(std::time::Instant::now);
277                    }
278                    Err(error) => {
279                        let msg = format!("Start failed: {error}");
280                        record_tray_action(&last_error, Some(msg.clone()));
281                        let _ = _tray.set_tooltip(Some(format!("XBP worktree-watch\n{msg}")));
282                        let _ = last_error_item.set_text(truncate_menu_text(&msg, 90));
283                        if is_tray_background_child() {
284                            tracing::warn!("{msg}");
285                            let _ = append_tray_action_log(&msg);
286                        } else {
287                            eprintln!("{msg}");
288                        }
289                    }
290                }
291            }
292            if event.id == stop_item.id() {
293                let target = target.clone();
294                match stop_watchers((*target).clone()) {
295                    Ok(message) => {
296                        record_tray_action(&last_error, None);
297                        if is_tray_background_child() {
298                            tracing::info!("{message}");
299                        } else {
300                            println!("{message}");
301                        }
302                        last_poll = std::time::Instant::now()
303                            .checked_sub(Duration::from_millis(STATUS_POLL_MS))
304                            .unwrap_or_else(std::time::Instant::now);
305                    }
306                    Err(error) => {
307                        let msg = format!("Stop failed: {error}");
308                        record_tray_action(&last_error, Some(msg.clone()));
309                        let _ = _tray.set_tooltip(Some(format!("XBP worktree-watch\n{msg}")));
310                        let _ = last_error_item.set_text(truncate_menu_text(&msg, 90));
311                        if is_tray_background_child() {
312                            tracing::warn!("{msg}");
313                            let _ = append_tray_action_log(&msg);
314                        } else {
315                            eprintln!("{msg}");
316                        }
317                    }
318                }
319            }
320            if event.id == stats_item.id() {
321                match collect_worktree_watch_tray_snapshot(&target) {
322                    Ok(snapshot) => {
323                        if is_tray_background_child() {
324                            // Background tray has no console — write a snap log under ~/.xbp.
325                            let _ = write_tray_stats_snapshot(&snapshot);
326                            let _ = _tray.set_tooltip(Some(format!(
327                                "XBP worktree-watch stats written to ~/.xbp/worktree-watch-tray-stats.txt\n{}",
328                                snapshot.stats_line.clone().unwrap_or_default()
329                            )));
330                        } else {
331                            print_stats_report(&snapshot);
332                        }
333                    }
334                    Err(error) => {
335                        let msg = format!("Stats failed: {error}");
336                        record_tray_action(&last_error, Some(msg.clone()));
337                        if is_tray_background_child() {
338                            tracing::warn!("{msg}");
339                            let _ = append_tray_action_log(&msg);
340                        } else {
341                            eprintln!("{msg}");
342                        }
343                    }
344                }
345            }
346        }
347
348        let should_poll = auto_refresh.is_checked()
349            && last_poll.elapsed() >= Duration::from_millis(STATUS_POLL_MS);
350        if should_poll {
351            last_poll = std::time::Instant::now();
352            match collect_worktree_watch_tray_snapshot(&target) {
353                Ok(snapshot) => {
354                    let err = last_error
355                        .lock()
356                        .ok()
357                        .and_then(|guard| guard.clone());
358                    apply_snapshot_to_tray(
359                        &_tray,
360                        &status_item,
361                        &start_item,
362                        &stop_item,
363                        &last_error_item,
364                        &repos_submenu,
365                        &mut repo_menu_items,
366                        &snapshot,
367                        err.as_deref(),
368                    );
369                    if let Ok(mut guard) = last_snapshot.lock() {
370                        *guard = Some(snapshot);
371                    }
372                }
373                Err(error) => {
374                    let _ = _tray.set_tooltip(Some(format!("XBP watch error: {error}")));
375                    let _ = status_item.set_text(format!("Status: error ({error})"));
376                    let _ = last_error_item.set_text(truncate_menu_text(
377                        &format!("Last error: {error}"),
378                        90,
379                    ));
380                }
381            }
382        }
383
384        thread::sleep(Duration::from_millis(MENU_POLL_MS));
385    }
386
387    if !is_tray_background_child() {
388        println!("Worktree-watch tray closed.");
389    }
390    Ok(())
391}
392
393fn record_tray_action(slot: &Arc<Mutex<Option<String>>>, message: Option<String>) {
394    if let Ok(mut guard) = slot.lock() {
395        *guard = message;
396    }
397}
398
399fn truncate_menu_text(text: &str, max_chars: usize) -> String {
400    let trimmed = text.trim().replace('\n', " ");
401    if trimmed.chars().count() <= max_chars {
402        return trimmed;
403    }
404    let mut out = trimmed.chars().take(max_chars.saturating_sub(1)).collect::<String>();
405    out.push('…');
406    out
407}
408
409fn append_tray_action_log(message: &str) -> Result<(), String> {
410    let path = ensure_global_xbp_paths()?
411        .root_dir
412        .join("worktree-watch-tray.log");
413    let line = format!("{} {message}\n", Utc::now().to_rfc3339());
414    use std::io::Write;
415    let mut file = fs::OpenOptions::new()
416        .create(true)
417        .append(true)
418        .open(&path)
419        .map_err(|error| format!("Failed to open tray log {}: {error}", path.display()))?;
420    file.write_all(line.as_bytes())
421        .map_err(|error| format!("Failed to write tray log {}: {error}", path.display()))
422}
423
424fn write_tray_stats_snapshot(snapshot: &WorktreeWatchTraySnapshot) -> Result<(), String> {
425    let path = ensure_global_xbp_paths()?
426        .root_dir
427        .join("worktree-watch-tray-stats.txt");
428    let mut body = String::new();
429    body.push_str(&format!("updated_at: {}\n", Utc::now().to_rfc3339()));
430    body.push_str(&format!("target: {}\n", snapshot.target_label));
431    body.push_str(&format!("mode: {}\n", snapshot.mode));
432    body.push_str(&format!(
433        "running: {} (parent={}, repos={}/{})\n",
434        snapshot.any_running,
435        snapshot.parent_watcher_running,
436        snapshot.running_watchers,
437        snapshot.repository_count
438    ));
439    body.push_str(&format!(
440        "backlog: {} records / {} files unsynced\n",
441        snapshot.total_unsynced_records, snapshot.total_unsynced_files
442    ));
443    if let Some(line) = &snapshot.stats_line {
444        body.push_str(&format!("summary: {line}\n"));
445    }
446    for repo in &snapshot.repositories {
447        let flag = if repo.running { "ON" } else { "off" };
448        body.push_str(&format!(
449            "  [{flag}] {}/{} ({}) unsynced={} {}\n",
450            repo.owner, repo.name, repo.branch, repo.unsynced_records, repo.root
451        ));
452    }
453    fs::write(&path, body)
454        .map_err(|error| format!("Failed to write tray stats {}: {error}", path.display()))
455}
456
457fn append_tray_target_args(
458    command: &mut Command,
459    target: &WorktreeWatchTargetOptions,
460) -> Result<(), String> {
461    if let Some(parent) = target.parent.as_ref() {
462        command.arg("--parent");
463        command.arg(parent);
464        return Ok(());
465    }
466    if !target.repos.is_empty() {
467        command.arg("--repos");
468        for repo in &target.repos {
469            command.arg(repo);
470        }
471        return Ok(());
472    }
473    if let Some(repo) = target.repo.as_ref() {
474        command.arg("--repo");
475        command.arg(repo);
476    }
477    Ok(())
478}
479
480fn tray_state_file() -> Result<PathBuf, String> {
481    Ok(ensure_global_xbp_paths()?.root_dir.join(TRAY_STATE_FILE_NAME))
482}
483
484fn target_key(target: &WorktreeWatchTargetOptions) -> String {
485    if let Some(parent) = target.parent.as_ref() {
486        return format!("parent:{}", parent.display());
487    }
488    if !target.repos.is_empty() {
489        return format!(
490            "repos:{}",
491            target
492                .repos
493                .iter()
494                .map(|path| path.display().to_string())
495                .collect::<Vec<_>>()
496                .join(",")
497        );
498    }
499    if let Some(repo) = target.repo.as_ref() {
500        return format!("repo:{}", repo.display());
501    }
502    "current".to_string()
503}
504
505fn read_tray_instance_state(path: &Path) -> Result<Option<WorktreeWatchTrayInstanceState>, String> {
506    if !path.exists() {
507        return Ok(None);
508    }
509    let raw = fs::read_to_string(path)
510        .map_err(|error| format!("Failed to read tray state {}: {error}", path.display()))?;
511    serde_json::from_str(&raw)
512        .map(Some)
513        .map_err(|error| format!("Failed to parse tray state {}: {error}", path.display()))
514}
515
516fn write_tray_instance_state(
517    path: &Path,
518    target: &WorktreeWatchTargetOptions,
519) -> Result<(), String> {
520    let executable = std::env::current_exe()
521        .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
522    let state = WorktreeWatchTrayInstanceState {
523        pid: std::process::id(),
524        executable: executable.display().to_string(),
525        target_key: target_key(target),
526        started_at: Utc::now(),
527    };
528    let rendered = serde_json::to_string_pretty(&state)
529        .map_err(|error| format!("Failed to serialize tray state: {error}"))?;
530    fs::write(path, format!("{rendered}\n"))
531        .map_err(|error| format!("Failed to write tray state {}: {error}", path.display()))
532}
533
534fn clear_tray_instance_state(path: &Path) -> Result<(), String> {
535    if path.exists() {
536        fs::remove_file(path)
537            .map_err(|error| format!("Failed to remove tray state {}: {error}", path.display()))?;
538    }
539    Ok(())
540}
541
542fn tray_process_alive(pid: u32) -> bool {
543    let system = System::new_all();
544    system.process(Pid::from_u32(pid)).is_some()
545}
546
547fn existing_tray_instance_pid() -> Option<u32> {
548    let path = tray_state_file().ok()?;
549    let state = read_tray_instance_state(&path).ok()??;
550    if tray_process_alive(state.pid) {
551        return Some(state.pid);
552    }
553    let _ = clear_tray_instance_state(&path);
554    None
555}
556
557/// Best-effort kill of a detached worktree-watch tray so `cargo install` can
558/// replace the XBP binary (tray holds the same executable open).
559pub fn stop_worktree_watch_tray_before_install() {
560    let Some(pid) = existing_tray_instance_pid() else {
561        return;
562    };
563    if pid == std::process::id() {
564        return;
565    }
566
567    let system = System::new_all();
568    if let Some(process) = system.process(Pid::from_u32(pid)) {
569        if process.kill() {
570            println!("Stopped worktree-watch tray (pid {pid}) before install.");
571        } else {
572            println!(
573                "Warning: could not stop worktree-watch tray (pid {pid}) before install."
574            );
575        }
576    }
577
578    if let Ok(path) = tray_state_file() {
579        let _ = clear_tray_instance_state(&path);
580    }
581}
582
583fn acquire_tray_instance_guard(
584    target: &WorktreeWatchTargetOptions,
585) -> Result<TrayInstanceGuard, String> {
586    let state_file = tray_state_file()?;
587    if let Some(existing_pid) = existing_tray_instance_pid() {
588        if existing_pid != std::process::id() {
589            return Err(format!(
590                "Worktree-watch tray already running (pid {existing_pid}). Quit the existing tray icon first."
591            ));
592        }
593    }
594    write_tray_instance_state(&state_file, target)?;
595    Ok(TrayInstanceGuard { state_file })
596}
597
598fn configure_detached_tray_process(command: &mut Command) {
599    #[cfg(windows)]
600    {
601        use std::os::windows::process::CommandExt;
602        const CREATE_NO_WINDOW: u32 = 0x08000000;
603        const DETACHED_PROCESS: u32 = 0x00000008;
604        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
605        command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
606    }
607
608    #[cfg(unix)]
609    {
610        use std::os::unix::process::CommandExt;
611        unsafe {
612            command.pre_exec(|| {
613                extern "C" {
614                    fn setsid() -> i32;
615                }
616                let _ = setsid();
617                Ok(())
618            });
619        }
620    }
621}
622
623fn start_watchers(
624    target: WorktreeWatchTargetOptions,
625    sync_interval_seconds: u64,
626) -> Result<String, String> {
627    // Multi-repo without parent: start each repo detached.
628    if !target.repos.is_empty() && target.parent.is_none() {
629        let mut started = 0usize;
630        for repo in &target.repos {
631            let rt = tokio::runtime::Builder::new_current_thread()
632                .enable_all()
633                .build()
634                .map_err(|error| format!("Failed to start async runtime: {error}"))?;
635            rt.block_on(run_worktree_watch_start(WorktreeWatchStartOptions {
636                target: WorktreeWatchTargetOptions {
637                    repo: Some(repo.clone()),
638                    parent: None,
639                    repos: Vec::new(),
640                },
641                detach: true,
642                sync_interval_seconds,
643                once: false,
644            }))?;
645            started += 1;
646        }
647        return Ok(format!("Started {started} detached worktree watcher(s)."));
648    }
649
650    let rt = tokio::runtime::Builder::new_current_thread()
651        .enable_all()
652        .build()
653        .map_err(|error| format!("Failed to start async runtime: {error}"))?;
654    rt.block_on(run_worktree_watch_start(WorktreeWatchStartOptions {
655        target,
656        detach: true,
657        sync_interval_seconds,
658        once: false,
659    }))?;
660    Ok("Started detached worktree watcher.".to_string())
661}
662
663fn stop_watchers(target: WorktreeWatchTargetOptions) -> Result<String, String> {
664    if !target.repos.is_empty() && target.parent.is_none() {
665        let mut stopped_msgs = Vec::new();
666        for repo in &target.repos {
667            run_worktree_watch_stop(WorktreeWatchStopOptions {
668                target: WorktreeWatchTargetOptions {
669                    repo: Some(repo.clone()),
670                    parent: None,
671                    repos: Vec::new(),
672                },
673                force: false,
674            })?;
675            stopped_msgs.push(repo.display().to_string());
676        }
677        return Ok(format!(
678            "Stop requested for {} repo(s).",
679            stopped_msgs.len()
680        ));
681    }
682
683    run_worktree_watch_stop(WorktreeWatchStopOptions {
684        target,
685        force: false,
686    })?;
687    Ok("Stop requested.".to_string())
688}
689
690fn apply_snapshot_to_tray(
691    tray: &tray_icon::TrayIcon,
692    status_item: &muda::MenuItem,
693    start_item: &muda::MenuItem,
694    stop_item: &muda::MenuItem,
695    last_error_item: &muda::MenuItem,
696    repos_submenu: &muda::Submenu,
697    repo_menu_items: &mut Vec<muda::MenuItem>,
698    snapshot: &WorktreeWatchTraySnapshot,
699    last_error: Option<&str>,
700) {
701    let state = if snapshot.any_running { "ON" } else { "OFF" };
702    let mut tooltip = format!(
703        "XBP worktree-watch: {state}\n{}\n{}",
704        snapshot.target_label,
705        snapshot
706            .stats_line
707            .clone()
708            .unwrap_or_else(|| "no stats".to_string())
709    );
710    if let Some(error) = last_error {
711        tooltip.push('\n');
712        tooltip.push_str(error);
713    }
714    let _ = tray.set_tooltip(Some(tooltip));
715
716    let status_text = if snapshot.parent_watcher_running {
717        format!(
718            "Status: ON (parent pid {}) · {} unsynced",
719            snapshot.parent_watcher_pid.unwrap_or(0),
720            snapshot.total_unsynced_records
721        )
722    } else if snapshot.any_running {
723        format!(
724            "Status: ON ({}/{} watching) · {} unsynced",
725            snapshot.running_watchers, snapshot.repository_count, snapshot.total_unsynced_records
726        )
727    } else {
728        format!(
729            "Status: OFF · {} repo(s) · {} unsynced",
730            snapshot.repository_count, snapshot.total_unsynced_records
731        )
732    };
733    let _ = status_item.set_text(status_text);
734
735    if let Some(error) = last_error {
736        let _ = last_error_item.set_text(truncate_menu_text(
737            &format!("Last error: {error}"),
738            90,
739        ));
740    } else {
741        let _ = last_error_item.set_text("Last error: none");
742    }
743
744    // Enable start when nothing is running; stop when something is.
745    let _ = start_item.set_enabled(!snapshot.any_running);
746    let _ = stop_item.set_enabled(snapshot.any_running);
747
748    let _ = repos_submenu.set_text(format!(
749        "Repositories ({}/{} watching)",
750        snapshot.running_watchers, snapshot.repository_count
751    ));
752
753    rebuild_repos_submenu(repos_submenu, repo_menu_items, snapshot);
754}
755
756fn rebuild_repos_submenu(
757    repos_submenu: &muda::Submenu,
758    repo_menu_items: &mut Vec<muda::MenuItem>,
759    snapshot: &WorktreeWatchTraySnapshot,
760) {
761    for item in repo_menu_items.drain(..) {
762        let _ = repos_submenu.remove(&item);
763    }
764
765    if snapshot.repositories.is_empty() {
766        let empty = muda::MenuItem::new("No repositories resolved", false, None);
767        let _ = repos_submenu.append(&empty);
768        repo_menu_items.push(empty);
769        return;
770    }
771
772    const MAX_REPO_ROWS: usize = 24;
773    for repo in snapshot.repositories.iter().take(MAX_REPO_ROWS) {
774        let flag = if repo.running { "ON " } else { "off" };
775        let label = truncate_menu_text(
776            &format!(
777                "[{flag}] {}/{} · {} unsynced · {}",
778                repo.owner, repo.name, repo.unsynced_records, repo.branch
779            ),
780            96,
781        );
782        let item = muda::MenuItem::new(label, false, None);
783        let _ = repos_submenu.append(&item);
784        repo_menu_items.push(item);
785    }
786
787    if snapshot.repositories.len() > MAX_REPO_ROWS {
788        let more = muda::MenuItem::new(
789            format!(
790                "…and {} more (use status --stats)",
791                snapshot.repositories.len() - MAX_REPO_ROWS
792            ),
793            false,
794            None,
795        );
796        let _ = repos_submenu.append(&more);
797        repo_menu_items.push(more);
798    }
799}
800
801fn print_stats_report(snapshot: &WorktreeWatchTraySnapshot) {
802    println!("── XBP worktree-watch stats ──");
803    println!("Target:  {}", snapshot.target_label);
804    println!("Mode:    {}", snapshot.mode);
805    println!(
806        "Running: {} (parent={}, repos={}/{})",
807        snapshot.any_running,
808        snapshot.parent_watcher_running,
809        snapshot.running_watchers,
810        snapshot.repository_count
811    );
812    println!(
813        "Backlog: {} records / {} files unsynced",
814        snapshot.total_unsynced_records, snapshot.total_unsynced_files
815    );
816    if let Some(line) = &snapshot.stats_line {
817        println!("Summary: {line}");
818    }
819    for repo in &snapshot.repositories {
820        let flag = if repo.running { "ON " } else { "off" };
821        println!(
822            "  [{flag}] {}/{} ({})  unsynced={}  {}",
823            repo.owner,
824            repo.name,
825            repo.branch,
826            repo.unsynced_records,
827            repo.root
828        );
829    }
830    println!("──────────────────────────────");
831}
832
833fn describe_target(target: &WorktreeWatchTargetOptions) -> String {
834    if let Some(parent) = target.parent.as_ref() {
835        return format!("parent {}", parent.display());
836    }
837    if !target.repos.is_empty() {
838        return format!(
839            "{} repo(s): {}",
840            target.repos.len(),
841            target
842                .repos
843                .iter()
844                .map(|path| path.display().to_string())
845                .collect::<Vec<_>>()
846                .join(", ")
847        );
848    }
849    if let Some(repo) = target.repo.as_ref() {
850        return format!("repo {}", repo.display());
851    }
852    "current repository".to_string()
853}
854
855fn platform_tray_hint() -> &'static str {
856    if cfg!(windows) {
857        "Windows system tray"
858    } else if cfg!(target_os = "linux") {
859        "Linux StatusNotifier / GNOME tray"
860    } else if cfg!(target_os = "macos") {
861        "macOS menu bar"
862    } else {
863        "system tray"
864    }
865}
866
867/// Parse tray options without depending on clap types (used from handler).
868pub fn tray_options_from_parts(
869    repo: Option<PathBuf>,
870    parent: Option<PathBuf>,
871    repos: Vec<PathBuf>,
872    sync_interval_seconds: u64,
873) -> Result<WorktreeWatchTrayOptions, String> {
874    if parent.is_some() && repo.is_some() {
875        return Err("Pass either `--repo` or `--parent`, not both.".to_string());
876    }
877    if parent.is_some() && !repos.is_empty() {
878        return Err("Pass either `--repos` or `--parent`, not both.".to_string());
879    }
880    if repo.is_some() && !repos.is_empty() {
881        return Err("Pass either `--repo` or `--repos`, not both.".to_string());
882    }
883    Ok(WorktreeWatchTrayOptions {
884        target: WorktreeWatchTargetOptions {
885            repo,
886            parent,
887            repos,
888        },
889        sync_interval_seconds,
890    })
891}