use crate::commands::worktree_watch::{
collect_worktree_watch_tray_snapshot, run_worktree_watch_start, run_worktree_watch_stop,
WorktreeWatchStartOptions, WorktreeWatchStopOptions, WorktreeWatchTargetOptions,
WorktreeWatchTraySnapshot,
};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
const TRAY_ICON_BYTES: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/xylex-app-icon.png"));
const STATUS_POLL_MS: u64 = 2_500;
const MENU_POLL_MS: u64 = 120;
#[derive(Debug, Clone)]
pub struct WorktreeWatchTrayOptions {
pub target: WorktreeWatchTargetOptions,
pub sync_interval_seconds: u64,
}
pub fn run_worktree_watch_tray(options: WorktreeWatchTrayOptions) -> Result<(), String> {
run_tray_loop(options)
}
fn run_tray_loop(options: WorktreeWatchTrayOptions) -> Result<(), String> {
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use tray_icon::{Icon, TrayIconBuilder};
let icon = Icon::from_rgba(load_rgba_icon()?, 320, 320)
.map_err(|error| format!("Failed to create tray icon: {error}"))?;
let target = Arc::new(options.target.clone());
let sync_interval_seconds = options.sync_interval_seconds;
let quit_flag = Arc::new(AtomicBool::new(false));
let start_item = MenuItem::new("Start watching", true, None);
let stop_item = MenuItem::new("Stop watching", true, None);
let refresh_item = MenuItem::new("Refresh status", true, None);
let stats_item = MenuItem::new("Show stats (console)", true, None);
let status_item = MenuItem::new("Status: loading…", false, None);
let target_item = MenuItem::new(&format!("Target: {}", describe_target(&target)), false, None);
let quit_item = MenuItem::new("Quit tray", true, None);
let auto_refresh = CheckMenuItem::new("Auto-refresh status", true, true, None);
let repos_submenu = Submenu::new("Repositories", true);
let menu = Menu::new();
menu.append(&status_item)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&target_item)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&PredefinedMenuItem::separator())
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&start_item)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&stop_item)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&PredefinedMenuItem::separator())
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&refresh_item)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&auto_refresh)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&stats_item)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&repos_submenu)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&PredefinedMenuItem::separator())
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
menu.append(&quit_item)
.map_err(|error| format!("Failed to build tray menu: {error}"))?;
let tray = TrayIconBuilder::new()
.with_tooltip("XBP worktree-watch")
.with_icon(icon)
.with_menu(Box::new(menu))
.build()
.map_err(|error| format!("Failed to create tray icon: {error}"))?;
println!(
"XBP worktree-watch tray ready ({}) — right-click the tray icon for Start/Stop/Stats.",
platform_tray_hint()
);
println!("Target: {}", describe_target(&target));
if cfg!(target_os = "linux") {
println!(
"GNOME Shell note: StatusNotifier/AppIndicator support is required \
(e.g. AppIndicator extension on classic GNOME)."
);
}
let last_snapshot: Arc<Mutex<Option<WorktreeWatchTraySnapshot>>> = Arc::new(Mutex::new(None));
if let Ok(snapshot) = collect_worktree_watch_tray_snapshot(&target) {
apply_snapshot_to_tray(
&tray,
&status_item,
&start_item,
&stop_item,
&repos_submenu,
&snapshot,
);
if let Ok(mut guard) = last_snapshot.lock() {
*guard = Some(snapshot);
}
}
let menu_channel = MenuEvent::receiver();
let mut last_poll = std::time::Instant::now()
.checked_sub(Duration::from_millis(STATUS_POLL_MS))
.unwrap_or_else(std::time::Instant::now);
while !quit_flag.load(Ordering::SeqCst) {
while let Ok(event) = menu_channel.try_recv() {
if event.id == quit_item.id() {
quit_flag.store(true, Ordering::SeqCst);
break;
}
if event.id == refresh_item.id() || event.id == auto_refresh.id() {
last_poll = std::time::Instant::now()
.checked_sub(Duration::from_millis(STATUS_POLL_MS))
.unwrap_or_else(std::time::Instant::now);
}
if event.id == start_item.id() {
let target = target.clone();
match start_watchers((*target).clone(), sync_interval_seconds) {
Ok(message) => {
println!("{message}");
last_poll = std::time::Instant::now()
.checked_sub(Duration::from_millis(STATUS_POLL_MS))
.unwrap_or_else(std::time::Instant::now);
}
Err(error) => eprintln!("Start failed: {error}"),
}
}
if event.id == stop_item.id() {
let target = target.clone();
match stop_watchers((*target).clone()) {
Ok(message) => {
println!("{message}");
last_poll = std::time::Instant::now()
.checked_sub(Duration::from_millis(STATUS_POLL_MS))
.unwrap_or_else(std::time::Instant::now);
}
Err(error) => eprintln!("Stop failed: {error}"),
}
}
if event.id == stats_item.id() {
match collect_worktree_watch_tray_snapshot(&target) {
Ok(snapshot) => print_stats_report(&snapshot),
Err(error) => eprintln!("Stats failed: {error}"),
}
}
}
let should_poll = auto_refresh.is_checked()
&& last_poll.elapsed() >= Duration::from_millis(STATUS_POLL_MS);
if should_poll {
last_poll = std::time::Instant::now();
match collect_worktree_watch_tray_snapshot(&target) {
Ok(snapshot) => {
apply_snapshot_to_tray(
&tray,
&status_item,
&start_item,
&stop_item,
&repos_submenu,
&snapshot,
);
if let Ok(mut guard) = last_snapshot.lock() {
*guard = Some(snapshot);
}
}
Err(error) => {
let _ = tray.set_tooltip(Some(format!("XBP watch error: {error}")));
let _ = status_item.set_text(format!("Status: error ({error})"));
}
}
}
thread::sleep(Duration::from_millis(MENU_POLL_MS));
}
println!("Worktree-watch tray closed.");
Ok(())
}
fn start_watchers(
target: WorktreeWatchTargetOptions,
sync_interval_seconds: u64,
) -> Result<String, String> {
if !target.repos.is_empty() && target.parent.is_none() {
let mut started = 0usize;
for repo in &target.repos {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("Failed to start async runtime: {error}"))?;
rt.block_on(run_worktree_watch_start(WorktreeWatchStartOptions {
target: WorktreeWatchTargetOptions {
repo: Some(repo.clone()),
parent: None,
repos: Vec::new(),
},
detach: true,
sync_interval_seconds,
once: false,
}))?;
started += 1;
}
return Ok(format!("Started {started} detached worktree watcher(s)."));
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("Failed to start async runtime: {error}"))?;
rt.block_on(run_worktree_watch_start(WorktreeWatchStartOptions {
target,
detach: true,
sync_interval_seconds,
once: false,
}))?;
Ok("Started detached worktree watcher.".to_string())
}
fn stop_watchers(target: WorktreeWatchTargetOptions) -> Result<String, String> {
if !target.repos.is_empty() && target.parent.is_none() {
let mut stopped_msgs = Vec::new();
for repo in &target.repos {
run_worktree_watch_stop(WorktreeWatchStopOptions {
target: WorktreeWatchTargetOptions {
repo: Some(repo.clone()),
parent: None,
repos: Vec::new(),
},
force: false,
})?;
stopped_msgs.push(repo.display().to_string());
}
return Ok(format!(
"Stop requested for {} repo(s).",
stopped_msgs.len()
));
}
run_worktree_watch_stop(WorktreeWatchStopOptions {
target,
force: false,
})?;
Ok("Stop requested.".to_string())
}
fn apply_snapshot_to_tray(
tray: &tray_icon::TrayIcon,
status_item: &muda::MenuItem,
start_item: &muda::MenuItem,
stop_item: &muda::MenuItem,
repos_submenu: &muda::Submenu,
snapshot: &WorktreeWatchTraySnapshot,
) {
let state = if snapshot.any_running { "ON" } else { "OFF" };
let tooltip = format!(
"XBP worktree-watch: {state}\n{}\n{}",
snapshot.target_label,
snapshot
.stats_line
.clone()
.unwrap_or_else(|| "no stats".to_string())
);
let _ = tray.set_tooltip(Some(tooltip));
let status_text = if snapshot.parent_watcher_running {
format!(
"Status: ON (parent pid {}) · {} unsynced",
snapshot.parent_watcher_pid.unwrap_or(0),
snapshot.total_unsynced_records
)
} else if snapshot.any_running {
format!(
"Status: ON ({}/{} watching) · {} unsynced",
snapshot.running_watchers, snapshot.repository_count, snapshot.total_unsynced_records
)
} else {
format!(
"Status: OFF · {} repo(s) · {} unsynced",
snapshot.repository_count, snapshot.total_unsynced_records
)
};
let _ = status_item.set_text(status_text);
let _ = start_item.set_enabled(!snapshot.any_running);
let _ = stop_item.set_enabled(snapshot.any_running);
let _ = repos_submenu.set_text(format!(
"Repositories ({}/{} watching)",
snapshot.running_watchers, snapshot.repository_count
));
}
fn print_stats_report(snapshot: &WorktreeWatchTraySnapshot) {
println!("── XBP worktree-watch stats ──");
println!("Target: {}", snapshot.target_label);
println!("Mode: {}", snapshot.mode);
println!(
"Running: {} (parent={}, repos={}/{})",
snapshot.any_running,
snapshot.parent_watcher_running,
snapshot.running_watchers,
snapshot.repository_count
);
println!(
"Backlog: {} records / {} files unsynced",
snapshot.total_unsynced_records, snapshot.total_unsynced_files
);
if let Some(line) = &snapshot.stats_line {
println!("Summary: {line}");
}
for repo in &snapshot.repositories {
let flag = if repo.running { "ON " } else { "off" };
println!(
" [{flag}] {}/{} ({}) unsynced={} {}",
repo.owner,
repo.name,
repo.branch,
repo.unsynced_records,
repo.root
);
}
println!("──────────────────────────────");
}
fn describe_target(target: &WorktreeWatchTargetOptions) -> String {
if let Some(parent) = target.parent.as_ref() {
return format!("parent {}", parent.display());
}
if !target.repos.is_empty() {
return format!(
"{} repo(s): {}",
target.repos.len(),
target
.repos
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(repo) = target.repo.as_ref() {
return format!("repo {}", repo.display());
}
"current repository".to_string()
}
fn platform_tray_hint() -> &'static str {
if cfg!(windows) {
"Windows system tray"
} else if cfg!(target_os = "linux") {
"Linux StatusNotifier / GNOME tray"
} else if cfg!(target_os = "macos") {
"macOS menu bar"
} else {
"system tray"
}
}
fn load_rgba_icon() -> Result<Vec<u8>, String> {
let image = image::load_from_memory(TRAY_ICON_BYTES)
.map_err(|error| format!("Failed to decode tray icon: {error}"))?
.into_rgba8();
Ok(image.into_raw())
}
pub fn tray_options_from_parts(
repo: Option<PathBuf>,
parent: Option<PathBuf>,
repos: Vec<PathBuf>,
sync_interval_seconds: u64,
) -> Result<WorktreeWatchTrayOptions, String> {
if parent.is_some() && repo.is_some() {
return Err("Pass either `--repo` or `--parent`, not both.".to_string());
}
if parent.is_some() && !repos.is_empty() {
return Err("Pass either `--repos` or `--parent`, not both.".to_string());
}
if repo.is_some() && !repos.is_empty() {
return Err("Pass either `--repo` or `--repos`, not both.".to_string());
}
Ok(WorktreeWatchTrayOptions {
target: WorktreeWatchTargetOptions {
repo,
parent,
repos,
},
sync_interval_seconds,
})
}