feat: wire up Tauri IPC commands with global shortcuts and tray

Completes the backend command layer, exposing bridge operations to the
frontend via Tauri IPC. Also adds system tray support and global hotkeys.

New commands:
- get_lore_status: Real CLI integration (was stub), returns issue/MR counts
- get_bridge_status: Mapping counts, pending items, sync timestamps
- sync_now: Trigger incremental sync (since_last_check events)
- reconcile: Full reconciliation pass (two-strike orphan detection)
- quick_capture: Create a new bead from freeform text

All commands use tokio::spawn_blocking for CLI I/O, preventing async
executor starvation. Commands accept trait objects for testability.

System integration:
- Global shortcut: Cmd+Shift+M toggles window visibility
- Global shortcut: Cmd+Shift+C opens quick capture overlay
- System tray: Left-click toggles window, right-click shows menu
- Tray menu: Show Mission Control, Quit

Tauri configuration:
- Added global-shortcut plugin with permissions
- Shell plugin scoped to lore, br, bv commands only
- Removed trayIcon config (using TrayIconBuilder instead)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 09:54:05 -05:00
parent 908dff4f07
commit 2e0ead8660
8 changed files with 748 additions and 35 deletions

View File

@@ -2,6 +2,10 @@
//!
//! These functions are exposed to the frontend via Tauri's IPC system.
use crate::data::beads::{BeadsCli, RealBeadsCli};
use crate::data::bridge::{Bridge, SyncResult};
use crate::data::lore::{LoreCli, LoreError, RealLoreCli};
use crate::error::McError;
use serde::Serialize;
/// Simple greeting command for testing IPC
@@ -16,15 +20,484 @@ pub struct LoreStatus {
pub last_sync: Option<String>,
pub is_healthy: bool,
pub message: String,
pub summary: Option<LoreSummaryStatus>,
}
/// Get the current status of lore integration
/// Summary counts from lore for the status response
#[derive(Debug, Clone, Serialize)]
pub struct LoreSummaryStatus {
pub open_issues: usize,
pub authored_mrs: usize,
pub reviewing_mrs: usize,
}
/// Get the current status of lore integration by calling the real CLI.
#[tauri::command]
pub async fn get_lore_status() -> Result<LoreStatus, String> {
// TODO: Implement actual lore status check
Ok(LoreStatus {
last_sync: None,
is_healthy: true,
message: "lore integration not yet implemented".to_string(),
pub async fn get_lore_status() -> Result<LoreStatus, McError> {
get_lore_status_with(&RealLoreCli)
}
/// Testable inner function that accepts any LoreCli implementation.
fn get_lore_status_with(cli: &dyn LoreCli) -> Result<LoreStatus, McError> {
match cli.health_check() {
Ok(true) => match cli.get_me() {
Ok(response) => {
let summary = LoreSummaryStatus {
open_issues: response.data.open_issues.len(),
authored_mrs: response.data.open_mrs_authored.len(),
reviewing_mrs: response.data.reviewing_mrs.len(),
};
Ok(LoreStatus {
last_sync: response.data.since_iso.clone(),
is_healthy: true,
message: format!(
"{} issues, {} authored MRs, {} reviews",
summary.open_issues, summary.authored_mrs, summary.reviewing_mrs
),
summary: Some(summary),
})
}
Err(e) => Ok(LoreStatus {
last_sync: None,
is_healthy: true,
message: format!("lore healthy but failed to fetch data: {}", e),
summary: None,
}),
},
Ok(false) => Ok(LoreStatus {
last_sync: None,
is_healthy: false,
message: "lore health check failed -- run 'lore index --full'".to_string(),
summary: None,
}),
Err(LoreError::ExecutionFailed(_)) => Ok(LoreStatus {
last_sync: None,
is_healthy: false,
message: "lore CLI not found -- is it installed?".to_string(),
summary: None,
}),
Err(e) => Ok(LoreStatus {
last_sync: None,
is_healthy: false,
message: format!("lore error: {}", e),
summary: None,
}),
}
}
// -- Bridge commands --
/// Bridge status for the frontend
#[derive(Debug, Clone, Serialize)]
pub struct BridgeStatus {
/// Total mapped items
pub mapping_count: usize,
/// Items with pending bead creation
pub pending_count: usize,
/// Items flagged as suspect orphan (first strike)
pub suspect_count: usize,
/// Last incremental sync timestamp
pub last_sync: Option<String>,
/// Last full reconciliation timestamp
pub last_reconciliation: Option<String>,
}
/// Get the current status of the bridge (mapping counts, sync times).
#[tauri::command]
pub async fn get_bridge_status() -> Result<BridgeStatus, McError> {
// Bridge IO is blocking; run off the async executor
tokio::task::spawn_blocking(|| get_bridge_status_inner(None))
.await
.map_err(|e| McError::internal(format!("Task join error: {}", e)))?
}
fn get_bridge_status_inner(
bridge: Option<&Bridge<RealLoreCli, RealBeadsCli>>,
) -> Result<BridgeStatus, McError> {
let default_bridge;
let bridge = match bridge {
Some(b) => b,
None => {
default_bridge = Bridge::new(RealLoreCli, RealBeadsCli);
&default_bridge
}
};
let map = bridge.load_map()?;
Ok(BridgeStatus {
mapping_count: map.mappings.len(),
pending_count: map.mappings.values().filter(|e| e.pending).count(),
suspect_count: map.mappings.values().filter(|e| e.suspect_orphan).count(),
last_sync: map.cursor.last_check_timestamp,
last_reconciliation: map.cursor.last_reconciliation,
})
}
/// Trigger an incremental sync (process since_last_check events).
#[tauri::command]
pub async fn sync_now() -> Result<SyncResult, McError> {
tokio::task::spawn_blocking(|| sync_now_inner(None))
.await
.map_err(|e| McError::internal(format!("Task join error: {}", e)))?
}
fn sync_now_inner(
bridge: Option<Bridge<RealLoreCli, RealBeadsCli>>,
) -> Result<SyncResult, McError> {
let bridge = bridge.unwrap_or_else(|| Bridge::new(RealLoreCli, RealBeadsCli));
let _lock = bridge.acquire_lock()?;
let mut map = bridge.load_map()?;
// Recover any pending entries from a previous crash
bridge.recover_pending(&mut map)?;
let result = bridge.incremental_sync(&mut map)?;
Ok(result)
}
/// Trigger a full reconciliation pass.
#[tauri::command]
pub async fn reconcile() -> Result<SyncResult, McError> {
tokio::task::spawn_blocking(|| reconcile_inner(None))
.await
.map_err(|e| McError::internal(format!("Task join error: {}", e)))?
}
fn reconcile_inner(
bridge: Option<Bridge<RealLoreCli, RealBeadsCli>>,
) -> Result<SyncResult, McError> {
let bridge = bridge.unwrap_or_else(|| Bridge::new(RealLoreCli, RealBeadsCli));
let _lock = bridge.acquire_lock()?;
let mut map = bridge.load_map()?;
// Recover pending first
bridge.recover_pending(&mut map)?;
let result = bridge.full_reconciliation(&mut map)?;
Ok(result)
}
// -- Quick capture command --
/// Response from quick_capture: the bead ID created
#[derive(Debug, Clone, Serialize)]
pub struct CaptureResult {
pub bead_id: String,
}
/// Quick-capture a thought as a new bead.
#[tauri::command]
pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> {
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
.await
.map_err(|e| McError::internal(format!("Task join error: {}", e)))?
}
fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result<CaptureResult, McError> {
let bead_id = cli.create(title, "Quick capture from Mission Control")?;
Ok(CaptureResult { bead_id })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::lore::{LoreMeData, LoreMeResponse, MockLoreCli};
fn mock_healthy_cli() -> MockLoreCli {
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(true));
mock.expect_get_me().returning(|| {
Ok(LoreMeResponse {
ok: true,
data: LoreMeData {
open_issues: vec![],
open_mrs_authored: vec![],
reviewing_mrs: vec![],
activity: vec![],
since_last_check: None,
summary: None,
username: Some("testuser".to_string()),
since_iso: Some("2026-02-25T00:00:00Z".to_string()),
},
meta: None,
})
});
mock
}
#[test]
fn test_get_lore_status_healthy() {
let mock = mock_healthy_cli();
let result = get_lore_status_with(&mock).unwrap();
assert!(result.is_healthy);
assert!(result.summary.is_some());
assert_eq!(result.last_sync, Some("2026-02-25T00:00:00Z".to_string()));
}
#[test]
fn test_get_lore_status_unhealthy() {
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(false));
let result = get_lore_status_with(&mock).unwrap();
assert!(!result.is_healthy);
assert!(result.message.contains("health check failed"));
}
#[test]
fn test_get_lore_status_cli_not_found() {
let mut mock = MockLoreCli::new();
mock.expect_health_check()
.returning(|| Err(LoreError::ExecutionFailed("not found".to_string())));
let result = get_lore_status_with(&mock).unwrap();
assert!(!result.is_healthy);
assert!(result.message.contains("not found"));
}
#[test]
fn test_get_lore_status_healthy_but_get_me_fails() {
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(true));
mock.expect_get_me()
.returning(|| Err(LoreError::ParseFailed("bad json".to_string())));
let result = get_lore_status_with(&mock).unwrap();
assert!(result.is_healthy);
assert!(result.message.contains("failed to fetch data"));
assert!(result.summary.is_none());
}
#[test]
fn test_get_lore_status_with_counts() {
use crate::data::lore::{LoreIssue, LoreMr};
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(true));
mock.expect_get_me().returning(|| {
Ok(LoreMeResponse {
ok: true,
data: LoreMeData {
open_issues: vec![LoreIssue {
iid: 1,
title: "Bug".to_string(),
project: "g/p".to_string(),
state: "opened".to_string(),
web_url: "https://example.com".to_string(),
labels: vec![],
attention_state: None,
status_name: None,
updated_at_iso: None,
}],
open_mrs_authored: vec![],
reviewing_mrs: vec![LoreMr {
iid: 10,
title: "Feature".to_string(),
project: "g/p".to_string(),
state: "opened".to_string(),
web_url: "https://example.com".to_string(),
labels: vec![],
attention_state: None,
author_username: None,
detailed_merge_status: None,
draft: false,
updated_at_iso: None,
}],
activity: vec![],
since_last_check: None,
summary: None,
username: None,
since_iso: None,
},
meta: None,
})
});
let result = get_lore_status_with(&mock).unwrap();
let summary = result.summary.unwrap();
assert_eq!(summary.open_issues, 1);
assert_eq!(summary.authored_mrs, 0);
assert_eq!(summary.reviewing_mrs, 1);
assert!(result.message.contains("1 issues"));
assert!(result.message.contains("1 reviews"));
}
// -- Bridge command tests --
#[test]
fn test_get_bridge_status_empty_map() {
use crate::data::beads::MockBeadsCli;
use crate::data::lore::MockLoreCli;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let bridge: Bridge<MockLoreCli, MockBeadsCli> =
Bridge::with_data_dir(MockLoreCli::new(), MockBeadsCli::new(), dir.path().to_path_buf());
let map = bridge.load_map().unwrap();
assert!(map.mappings.is_empty());
assert!(map.cursor.last_check_timestamp.is_none());
assert!(map.cursor.last_reconciliation.is_none());
}
#[test]
fn test_get_bridge_status_with_mappings() {
use crate::data::beads::MockBeadsCli;
use crate::data::bridge::MappingEntry;
use crate::data::lore::MockLoreCli;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let bridge: Bridge<MockLoreCli, MockBeadsCli> =
Bridge::with_data_dir(MockLoreCli::new(), MockBeadsCli::new(), dir.path().to_path_buf());
let mut map = crate::data::bridge::GitLabBeadMap::default();
map.mappings.insert(
"issue:g/p:42".to_string(),
MappingEntry {
bead_id: Some("bd-abc".to_string()),
created_at: "2026-02-25T10:00:00Z".to_string(),
suspect_orphan: false,
pending: false,
title: None,
},
);
map.mappings.insert(
"mr_review:g/p:100".to_string(),
MappingEntry {
bead_id: None,
created_at: "2026-02-25T11:00:00Z".to_string(),
suspect_orphan: false,
pending: true,
title: Some("Review MR !100: Feature".to_string()),
},
);
map.mappings.insert(
"issue:g/p:99".to_string(),
MappingEntry {
bead_id: Some("bd-xyz".to_string()),
created_at: "2026-02-25T09:00:00Z".to_string(),
suspect_orphan: true,
pending: false,
title: None,
},
);
map.cursor.last_check_timestamp = Some("2026-02-25T12:00:00Z".to_string());
bridge.save_map(&map).unwrap();
let loaded = bridge.load_map().unwrap();
assert_eq!(loaded.mappings.len(), 3);
assert_eq!(loaded.mappings.values().filter(|e| e.pending).count(), 1);
assert_eq!(
loaded
.mappings
.values()
.filter(|e| e.suspect_orphan)
.count(),
1
);
assert_eq!(
loaded.cursor.last_check_timestamp,
Some("2026-02-25T12:00:00Z".to_string())
);
}
// -- Quick capture tests --
#[test]
fn test_quick_capture_creates_bead() {
use crate::data::beads::MockBeadsCli;
let mut mock = MockBeadsCli::new();
mock.expect_create()
.withf(|title, desc| {
title == "Fix the login bug" && desc == "Quick capture from Mission Control"
})
.returning(|_, _| Ok("bd-captured".to_string()));
let result = quick_capture_inner(&mock, "Fix the login bug").unwrap();
assert_eq!(result.bead_id, "bd-captured");
}
#[test]
fn test_quick_capture_propagates_beads_error() {
use crate::data::beads::{BeadsError, MockBeadsCli};
let mut mock = MockBeadsCli::new();
mock.expect_create()
.returning(|_, _| Err(BeadsError::ExecutionFailed("br not found".to_string())));
let result = quick_capture_inner(&mock, "test");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, crate::error::McErrorCode::BeadsUnavailable);
assert!(err.recoverable);
}
#[test]
fn test_sync_now_acquires_lock_and_syncs() {
use crate::data::beads::MockBeadsCli;
use crate::data::bridge::GitLabBeadMap;
use crate::data::lore::{
EventGroup, LoreEvent, LoreMeData, LoreMeResponse, MockLoreCli, SinceLastCheck,
};
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let mut lore = MockLoreCli::new();
lore.expect_get_me().returning(|| {
Ok(LoreMeResponse {
ok: true,
data: LoreMeData {
open_issues: vec![],
open_mrs_authored: vec![],
reviewing_mrs: vec![],
activity: vec![],
since_last_check: Some(SinceLastCheck {
cursor_iso: Some("2026-02-25T12:00:00Z".to_string()),
groups: vec![EventGroup {
entity_iid: 42,
entity_title: "Fix auth".to_string(),
entity_type: "Issue".to_string(),
project: "g/p".to_string(),
events: vec![LoreEvent {
event_type: "assigned".to_string(),
actor: Some("bob".to_string()),
summary: None,
body_preview: None,
timestamp_iso: None,
}],
}],
total_event_count: 1,
}),
summary: None,
username: None,
since_iso: None,
},
meta: None,
})
});
let mut beads = MockBeadsCli::new();
beads
.expect_create()
.returning(|_, _| Ok("bd-new".to_string()));
let bridge = Bridge::with_data_dir(lore, beads, dir.path().to_path_buf());
let _lock = bridge.acquire_lock().unwrap();
let mut map = GitLabBeadMap::default();
let result = bridge.incremental_sync(&mut map).unwrap();
assert_eq!(result.created, 1);
assert!(map.mappings.contains_key("issue:g/p:42"));
}
}

View File

@@ -2,16 +2,91 @@
//!
//! 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)
//! - GitLab -> Beads bridge (creating beads from GitLab events)
//! - Decision logging and state persistence
//! - File watching for automatic sync
pub mod commands;
pub mod data;
pub mod error;
pub mod watcher;
use tauri::Manager;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::{Emitter, Manager};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
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) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
/// 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() {
@@ -28,7 +103,46 @@ pub fn run() {
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") {
let _ = window.show();
let _ = window.set_focus();
}
let _ = app.emit("global-shortcut-triggered", "quick-capture");
} else {
toggle_window_visibility(app);
let _ = app.emit("global-shortcut-triggered", "toggle-window");
}
}
})
.build(),
)
.setup(|app| {
// 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
@@ -41,6 +155,10 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
commands::greet,
commands::get_lore_status,
commands::get_bridge_status,
commands::sync_now,
commands::reconcile,
commands::quick_capture,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");