1use 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
57pub 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
64pub 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
79pub 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
117pub 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 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 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 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 }
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 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 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
557pub 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 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 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
867pub 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}