use crate::commands::worktree_watch::{
collect_worktree_watch_tray_snapshot, run_worktree_watch_start, run_worktree_watch_stop,
WorktreeWatchStartOptions, WorktreeWatchStopOptions, WorktreeWatchTargetOptions,
WorktreeWatchTraySnapshot,
};
use crate::config::ensure_global_xbp_paths;
use crate::utils::tray_runtime::{load_tray_icon_from_png, pump_tray_events};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use sysinfo::{Pid, System};
const TRAY_ICON_BYTES: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/xylex-app-icon.png"));
const TRAY_BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_TRAY_CHILD";
const TRAY_STATE_FILE_NAME: &str = "worktree-watch-tray.json";
const STATUS_POLL_MS: u64 = 2_500;
const MENU_POLL_MS: u64 = 120;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WorktreeWatchTrayInstanceState {
pid: u32,
executable: String,
target_key: String,
started_at: DateTime<Utc>,
}
struct TrayInstanceGuard {
state_file: PathBuf,
}
impl Drop for TrayInstanceGuard {
fn drop(&mut self) {
let _ = clear_tray_instance_state(&self.state_file);
}
}
#[derive(Debug, Clone)]
pub struct WorktreeWatchTrayOptions {
pub target: WorktreeWatchTargetOptions,
pub sync_interval_seconds: u64,
}
pub fn is_tray_background_child() -> bool {
std::env::var(TRAY_BACKGROUND_CHILD_ENV)
.ok()
.is_some_and(|value| value == "1")
}
pub fn prepare_background_tray_process() {
if !is_tray_background_child() {
return;
}
#[cfg(windows)]
{
unsafe {
use windows_sys::Win32::System::Console::FreeConsole;
let _ = FreeConsole();
}
}
}
pub fn spawn_detached_worktree_watch_tray(options: WorktreeWatchTrayOptions) -> Result<(), String> {
if let Some(existing_pid) = existing_tray_instance_pid() {
println!(
"Worktree-watch tray already running (pid {existing_pid}). Right-click the tray icon to quit before starting another."
);
return Ok(());
}
let executable = std::env::current_exe()
.map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
let mut command = Command::new(&executable);
command
.arg("worktree-watch")
.arg("tray")
.env(TRAY_BACKGROUND_CHILD_ENV, "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
append_tray_target_args(&mut command, &options.target)?;
command.arg("--sync-interval-seconds");
command.arg(options.sync_interval_seconds.to_string());
configure_detached_tray_process(&mut command);
command
.spawn()
.map_err(|error| format!("Failed to spawn background worktree-watch tray: {error}"))?;
println!(
"XBP worktree-watch tray started in the background ({}) — right-click the tray icon for Start/Stop/Stats.",
platform_tray_hint()
);
println!("Target: {}", describe_target(&Arc::new(options.target)));
Ok(())
}
pub fn run_worktree_watch_tray(options: WorktreeWatchTrayOptions) -> Result<(), String> {
prepare_background_tray_process();
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, TrayIconEvent};
let _instance_guard = acquire_tray_instance_guard(&options.target)?;
let icon: Icon = load_tray_icon_from_png(TRAY_ICON_BYTES)?;
let target = Arc::new(options.target.clone());
let sync_interval_seconds = options.sync_interval_seconds;
let quit_flag = Arc::new(AtomicBool::new(false));
let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let api_port = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
options.target.clone(),
sync_interval_seconds,
None,
);
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 last_error_item = MenuItem::new("Last error: none", 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 mut repo_menu_items: Vec<MenuItem> = Vec::new();
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(&last_error_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}"))?;
let _tray = tray;
if is_tray_background_child() {
tracing::info!(
"XBP worktree-watch tray ready ({})",
platform_tray_hint()
);
if let Some(port) = api_port {
tracing::info!("worktree-watch local API listening on http://127.0.0.1:{port}");
}
} else {
println!(
"XBP worktree-watch tray ready ({}) — right-click the tray icon for Start/Stop/Stats.",
platform_tray_hint()
);
println!("Target: {}", describe_target(&target));
if let Some(port) = api_port {
crate::commands::worktree_watch_api::print_api_listen_hint(port);
}
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,
&last_error_item,
&repos_submenu,
&mut repo_menu_items,
&snapshot,
None,
);
if let Ok(mut guard) = last_snapshot.lock() {
*guard = Some(snapshot);
}
}
let menu_channel = MenuEvent::receiver();
let tray_channel = TrayIconEvent::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) {
pump_tray_events();
while let Ok(_event) = tray_channel.try_recv() {
}
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) => {
record_tray_action(&last_error, None);
if is_tray_background_child() {
tracing::info!("{message}");
} else {
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) => {
let msg = format!("Start failed: {error}");
record_tray_action(&last_error, Some(msg.clone()));
let _ = _tray.set_tooltip(Some(format!("XBP worktree-watch\n{msg}")));
let _ = last_error_item.set_text(truncate_menu_text(&msg, 90));
if is_tray_background_child() {
tracing::warn!("{msg}");
let _ = append_tray_action_log(&msg);
} else {
eprintln!("{msg}");
}
}
}
}
if event.id == stop_item.id() {
let target = target.clone();
match stop_watchers((*target).clone()) {
Ok(message) => {
record_tray_action(&last_error, None);
if is_tray_background_child() {
tracing::info!("{message}");
} else {
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) => {
let msg = format!("Stop failed: {error}");
record_tray_action(&last_error, Some(msg.clone()));
let _ = _tray.set_tooltip(Some(format!("XBP worktree-watch\n{msg}")));
let _ = last_error_item.set_text(truncate_menu_text(&msg, 90));
if is_tray_background_child() {
tracing::warn!("{msg}");
let _ = append_tray_action_log(&msg);
} else {
eprintln!("{msg}");
}
}
}
}
if event.id == stats_item.id() {
match collect_worktree_watch_tray_snapshot(&target) {
Ok(snapshot) => {
if is_tray_background_child() {
let _ = write_tray_stats_snapshot(&snapshot);
let _ = _tray.set_tooltip(Some(format!(
"XBP worktree-watch stats written to ~/.xbp/worktree-watch-tray-stats.txt\n{}",
snapshot.stats_line.clone().unwrap_or_default()
)));
} else {
print_stats_report(&snapshot);
}
}
Err(error) => {
let msg = format!("Stats failed: {error}");
record_tray_action(&last_error, Some(msg.clone()));
if is_tray_background_child() {
tracing::warn!("{msg}");
let _ = append_tray_action_log(&msg);
} else {
eprintln!("{msg}");
}
}
}
}
}
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) => {
let err = last_error
.lock()
.ok()
.and_then(|guard| guard.clone());
apply_snapshot_to_tray(
&_tray,
&status_item,
&start_item,
&stop_item,
&last_error_item,
&repos_submenu,
&mut repo_menu_items,
&snapshot,
err.as_deref(),
);
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})"));
let _ = last_error_item.set_text(truncate_menu_text(
&format!("Last error: {error}"),
90,
));
}
}
}
thread::sleep(Duration::from_millis(MENU_POLL_MS));
}
if !is_tray_background_child() {
println!("Worktree-watch tray closed.");
}
Ok(())
}
fn record_tray_action(slot: &Arc<Mutex<Option<String>>>, message: Option<String>) {
if let Ok(mut guard) = slot.lock() {
*guard = message;
}
}
fn truncate_menu_text(text: &str, max_chars: usize) -> String {
let trimmed = text.trim().replace('\n', " ");
if trimmed.chars().count() <= max_chars {
return trimmed;
}
let mut out = trimmed.chars().take(max_chars.saturating_sub(1)).collect::<String>();
out.push('…');
out
}
fn append_tray_action_log(message: &str) -> Result<(), String> {
let path = ensure_global_xbp_paths()?
.root_dir
.join("worktree-watch-tray.log");
let line = format!("{} {message}\n", Utc::now().to_rfc3339());
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| format!("Failed to open tray log {}: {error}", path.display()))?;
file.write_all(line.as_bytes())
.map_err(|error| format!("Failed to write tray log {}: {error}", path.display()))
}
fn write_tray_stats_snapshot(snapshot: &WorktreeWatchTraySnapshot) -> Result<(), String> {
let path = ensure_global_xbp_paths()?
.root_dir
.join("worktree-watch-tray-stats.txt");
let mut body = String::new();
body.push_str(&format!("updated_at: {}\n", Utc::now().to_rfc3339()));
body.push_str(&format!("target: {}\n", snapshot.target_label));
body.push_str(&format!("mode: {}\n", snapshot.mode));
body.push_str(&format!(
"running: {} (parent={}, repos={}/{})\n",
snapshot.any_running,
snapshot.parent_watcher_running,
snapshot.running_watchers,
snapshot.repository_count
));
body.push_str(&format!(
"backlog: {} records / {} files unsynced\n",
snapshot.total_unsynced_records, snapshot.total_unsynced_files
));
if let Some(line) = &snapshot.stats_line {
body.push_str(&format!("summary: {line}\n"));
}
for repo in &snapshot.repositories {
let flag = if repo.running { "ON" } else { "off" };
body.push_str(&format!(
" [{flag}] {}/{} ({}) unsynced={} {}\n",
repo.owner, repo.name, repo.branch, repo.unsynced_records, repo.root
));
}
fs::write(&path, body)
.map_err(|error| format!("Failed to write tray stats {}: {error}", path.display()))
}
fn append_tray_target_args(
command: &mut Command,
target: &WorktreeWatchTargetOptions,
) -> Result<(), String> {
if let Some(parent) = target.parent.as_ref() {
command.arg("--parent");
command.arg(parent);
return Ok(());
}
if !target.repos.is_empty() {
command.arg("--repos");
for repo in &target.repos {
command.arg(repo);
}
return Ok(());
}
if let Some(repo) = target.repo.as_ref() {
command.arg("--repo");
command.arg(repo);
}
Ok(())
}
fn tray_state_file() -> Result<PathBuf, String> {
Ok(ensure_global_xbp_paths()?.root_dir.join(TRAY_STATE_FILE_NAME))
}
fn target_key(target: &WorktreeWatchTargetOptions) -> String {
if let Some(parent) = target.parent.as_ref() {
return format!("parent:{}", parent.display());
}
if !target.repos.is_empty() {
return format!(
"repos:{}",
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".to_string()
}
fn read_tray_instance_state(path: &Path) -> Result<Option<WorktreeWatchTrayInstanceState>, String> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path)
.map_err(|error| format!("Failed to read tray state {}: {error}", path.display()))?;
serde_json::from_str(&raw)
.map(Some)
.map_err(|error| format!("Failed to parse tray state {}: {error}", path.display()))
}
fn write_tray_instance_state(
path: &Path,
target: &WorktreeWatchTargetOptions,
) -> Result<(), String> {
let executable = std::env::current_exe()
.map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
let state = WorktreeWatchTrayInstanceState {
pid: std::process::id(),
executable: executable.display().to_string(),
target_key: target_key(target),
started_at: Utc::now(),
};
let rendered = serde_json::to_string_pretty(&state)
.map_err(|error| format!("Failed to serialize tray state: {error}"))?;
fs::write(path, format!("{rendered}\n"))
.map_err(|error| format!("Failed to write tray state {}: {error}", path.display()))
}
fn clear_tray_instance_state(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_file(path)
.map_err(|error| format!("Failed to remove tray state {}: {error}", path.display()))?;
}
Ok(())
}
fn tray_process_alive(pid: u32) -> bool {
let system = System::new_all();
system.process(Pid::from_u32(pid)).is_some()
}
fn existing_tray_instance_pid() -> Option<u32> {
let path = tray_state_file().ok()?;
let state = read_tray_instance_state(&path).ok()??;
if tray_process_alive(state.pid) {
return Some(state.pid);
}
let _ = clear_tray_instance_state(&path);
None
}
fn acquire_tray_instance_guard(
target: &WorktreeWatchTargetOptions,
) -> Result<TrayInstanceGuard, String> {
let state_file = tray_state_file()?;
if let Some(existing_pid) = existing_tray_instance_pid() {
if existing_pid != std::process::id() {
return Err(format!(
"Worktree-watch tray already running (pid {existing_pid}). Quit the existing tray icon first."
));
}
}
write_tray_instance_state(&state_file, target)?;
Ok(TrayInstanceGuard { state_file })
}
fn configure_detached_tray_process(command: &mut Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(|| {
extern "C" {
fn setsid() -> i32;
}
let _ = setsid();
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,
last_error_item: &muda::MenuItem,
repos_submenu: &muda::Submenu,
repo_menu_items: &mut Vec<muda::MenuItem>,
snapshot: &WorktreeWatchTraySnapshot,
last_error: Option<&str>,
) {
let state = if snapshot.any_running { "ON" } else { "OFF" };
let mut tooltip = format!(
"XBP worktree-watch: {state}\n{}\n{}",
snapshot.target_label,
snapshot
.stats_line
.clone()
.unwrap_or_else(|| "no stats".to_string())
);
if let Some(error) = last_error {
tooltip.push('\n');
tooltip.push_str(error);
}
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);
if let Some(error) = last_error {
let _ = last_error_item.set_text(truncate_menu_text(
&format!("Last error: {error}"),
90,
));
} else {
let _ = last_error_item.set_text("Last error: none");
}
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
));
rebuild_repos_submenu(repos_submenu, repo_menu_items, snapshot);
}
fn rebuild_repos_submenu(
repos_submenu: &muda::Submenu,
repo_menu_items: &mut Vec<muda::MenuItem>,
snapshot: &WorktreeWatchTraySnapshot,
) {
for item in repo_menu_items.drain(..) {
let _ = repos_submenu.remove(&item);
}
if snapshot.repositories.is_empty() {
let empty = muda::MenuItem::new("No repositories resolved", false, None);
let _ = repos_submenu.append(&empty);
repo_menu_items.push(empty);
return;
}
const MAX_REPO_ROWS: usize = 24;
for repo in snapshot.repositories.iter().take(MAX_REPO_ROWS) {
let flag = if repo.running { "ON " } else { "off" };
let label = truncate_menu_text(
&format!(
"[{flag}] {}/{} · {} unsynced · {}",
repo.owner, repo.name, repo.unsynced_records, repo.branch
),
96,
);
let item = muda::MenuItem::new(label, false, None);
let _ = repos_submenu.append(&item);
repo_menu_items.push(item);
}
if snapshot.repositories.len() > MAX_REPO_ROWS {
let more = muda::MenuItem::new(
format!(
"…and {} more (use status --stats)",
snapshot.repositories.len() - MAX_REPO_ROWS
),
false,
None,
);
let _ = repos_submenu.append(&more);
repo_menu_items.push(more);
}
}
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"
}
}
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,
})
}