Files
mission-control/src-tauri/src/lib.rs
teernisse d7056cc86f feat(bd-4s6): add bv triage commands for recommendations
Implements get_triage and get_next_pick Tauri commands that call
bv --robot-triage and bv --robot-next respectively.

Response types are frontend-friendly (specta::Type) with:
- TriageResponse: counts, top_picks, quick_wins, blockers_to_clear
- NextPickResponse: single best pick with claim_command

Includes 5 tests covering:
- Structured data transformation
- Empty list handling
- Error propagation (BvUnavailable, BvTriageFailed)
2026-02-26 11:00:15 -05:00

266 lines
10 KiB
Rust

//! Mission Control - ADHD-centric personal productivity hub
//!
//! This crate provides the Rust backend for Mission Control, handling:
//! - CLI integration with lore (GitLab data) and br (beads task management)
//! - GitLab -> Beads bridge (creating beads from GitLab events)
//! - Decision logging and state persistence
//! - File watching for automatic sync
pub mod app;
pub mod commands;
pub mod data;
pub mod error;
pub mod sync;
pub mod watcher;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::{Emitter, Manager};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
use tauri_specta::{collect_commands, Builder};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
/// Toggle the main window's visibility.
///
/// If the window is visible and focused, hide it.
/// If hidden or not focused, show and focus it.
fn toggle_window_visibility(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) {
if let Err(e) = window.hide() {
tracing::warn!("Failed to hide window: {}", e);
}
} else {
if let Err(e) = window.show() {
tracing::warn!("Failed to show window: {}", e);
}
if let Err(e) = window.set_focus() {
tracing::warn!("Failed to focus window: {}", e);
}
}
}
}
/// Set up the system tray icon with a menu.
fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show_item = MenuItemBuilder::with_id("show", "Show Mission Control").build(app)?;
let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.items(&[&show_item, &quit_item])
.build()?;
TrayIconBuilder::new()
.icon(
app.default_window_icon()
.cloned()
.expect("default-window-icon must be set in tauri.conf.json"),
)
.tooltip("Mission Control")
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => toggle_window_visibility(app),
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
button_state: tauri::tray::MouseButtonState::Up,
..
} = event
{
toggle_window_visibility(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
/// Register global hotkeys:
/// - Cmd+Shift+M: toggle window visibility
/// - Cmd+Shift+C: quick capture overlay
///
/// Must be called AFTER the global-shortcut plugin is initialized.
fn setup_global_shortcut(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let toggle_shortcut = Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyM);
let capture_shortcut = Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyC);
app.global_shortcut()
.register_multiple([toggle_shortcut, capture_shortcut])?;
tracing::info!("Registered global shortcuts: Cmd+Shift+M (toggle), Cmd+Shift+C (capture)");
Ok(())
}
/// Initialize the Tauri application with all plugins and commands.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize tracing
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mission_control=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
tracing::info!("Starting Mission Control");
// Build tauri-specta builder for type-safe IPC
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
commands::greet,
commands::get_lore_status,
commands::get_bridge_status,
commands::sync_now,
commands::reconcile,
commands::quick_capture,
commands::read_state,
commands::write_state,
commands::clear_state,
commands::get_triage,
commands::get_next_pick,
commands::close_bead,
commands::log_decision,
commands::update_item,
]);
// Export TypeScript bindings in debug builds
#[cfg(debug_assertions)]
builder
.export(
specta_typescript::Typescript::default(),
"../src/lib/bindings.ts",
)
.expect("Failed to export TypeScript bindings");
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state == ShortcutState::Pressed {
let capture =
Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyC);
if shortcut == &capture {
// Show window and signal the frontend to open capture overlay
if let Some(window) = app.get_webview_window("main") {
if let Err(e) = window.show() {
tracing::warn!("Failed to show window for capture: {}", e);
}
if let Err(e) = window.set_focus() {
tracing::warn!("Failed to focus window for capture: {}", e);
}
}
if let Err(e) = app.emit("global-shortcut-triggered", "quick-capture") {
tracing::error!("Failed to emit quick-capture event: {}", e);
}
} else {
toggle_window_visibility(app);
if let Err(e) = app.emit("global-shortcut-triggered", "toggle-window") {
tracing::error!("Failed to emit toggle-window event: {}", e);
}
}
}
})
.build(),
)
.setup(|app| {
use data::beads::RealBeadsCli;
use data::bridge::Bridge;
use data::lore::RealLoreCli;
// 1. Ensure data directories exist
let config = app::AppConfig::default();
if let Err(e) = app::ensure_data_dir(&config) {
tracing::error!("Failed to create data directory: {}", e);
// Continue anyway - commands will fail gracefully
}
// 2. Clean up orphaned tmp files from previous crashes
{
let bridge: Bridge<RealLoreCli, RealBeadsCli> =
Bridge::new(RealLoreCli, RealBeadsCli);
if let Err(e) = bridge.cleanup_tmp_files() {
tracing::warn!("Failed to clean up tmp files: {}", e);
}
}
// 3. Verify CLI dependencies (async, spawned to not block startup)
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let (cli_available, warnings) = app::verify_cli_dependencies().await;
// Log warnings
for warning in &warnings {
match warning {
app::StartupWarning::LoreMissing => {
tracing::warn!("lore CLI not found - GitLab sync will be unavailable");
}
app::StartupWarning::BrMissing => {
tracing::warn!("br CLI not found - beads integration will be unavailable");
}
app::StartupWarning::BvMissing => {
tracing::warn!("bv CLI not found - triage features will be unavailable");
}
_ => {}
}
}
// Emit startup warnings to frontend
if !warnings.is_empty() {
if let Err(e) = app_handle.emit("startup-warnings", &warnings) {
tracing::error!("Failed to emit startup warnings: {}", e);
}
}
// Emit CLI availability to frontend
if let Err(e) = app_handle.emit("cli-availability", &cli_available) {
tracing::error!("Failed to emit CLI availability: {}", e);
}
// 4. Trigger startup reconciliation if CLIs are available
if cli_available.lore && cli_available.br {
tracing::info!("Triggering startup reconciliation");
// The frontend will call reconcile() command when ready
if let Err(e) = app_handle.emit("startup-sync-ready", ()) {
tracing::error!("Failed to emit startup-sync-ready: {}", e);
}
}
});
// Set up system tray
if let Err(e) = setup_tray(app) {
tracing::error!("Failed to setup system tray: {}", e);
}
// Set up global shortcut
if let Err(e) = setup_global_shortcut(app) {
tracing::error!("Failed to setup global shortcut: {}", e);
}
// Start file watcher for lore.db changes
if let Some(watcher) = watcher::start_lore_watcher(app.handle().clone()) {
// Store the watcher in app state to keep it alive
app.manage(std::sync::Mutex::new(Some(watcher)));
tracing::info!("Lore file watcher started");
}
#[cfg(debug_assertions)]
{
// Open devtools in debug mode
if let Some(window) = app.get_webview_window("main") {
window.open_devtools();
}
}
Ok(())
})
.invoke_handler(builder.invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}