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:
12
src-tauri/capabilities/default.json
Normal file
12
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capabilities for Mission Control",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"shell:allow-open",
|
||||||
|
"global-shortcut:allow-register",
|
||||||
|
"global-shortcut:allow-unregister",
|
||||||
|
"global-shortcut:allow-is-registered"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{}
|
{"default":{"identifier":"default","description":"Default capabilities for Mission Control","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","global-shortcut:allow-register","global-shortcut:allow-unregister","global-shortcut:allow-is-registered"]}}
|
||||||
@@ -2354,6 +2354,72 @@
|
|||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:default",
|
||||||
|
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-is-registered",
|
||||||
|
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register",
|
||||||
|
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register-all",
|
||||||
|
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister",
|
||||||
|
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister-all",
|
||||||
|
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-is-registered",
|
||||||
|
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register",
|
||||||
|
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register-all",
|
||||||
|
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister",
|
||||||
|
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister-all",
|
||||||
|
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2354,6 +2354,72 @@
|
|||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:default",
|
||||||
|
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-is-registered",
|
||||||
|
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register",
|
||||||
|
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register-all",
|
||||||
|
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister",
|
||||||
|
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister-all",
|
||||||
|
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-is-registered",
|
||||||
|
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register",
|
||||||
|
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register-all",
|
||||||
|
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister",
|
||||||
|
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister-all",
|
||||||
|
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! These functions are exposed to the frontend via Tauri's IPC system.
|
//! 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;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// Simple greeting command for testing IPC
|
/// Simple greeting command for testing IPC
|
||||||
@@ -16,15 +20,484 @@ pub struct LoreStatus {
|
|||||||
pub last_sync: Option<String>,
|
pub last_sync: Option<String>,
|
||||||
pub is_healthy: bool,
|
pub is_healthy: bool,
|
||||||
pub message: String,
|
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]
|
#[tauri::command]
|
||||||
pub async fn get_lore_status() -> Result<LoreStatus, String> {
|
pub async fn get_lore_status() -> Result<LoreStatus, McError> {
|
||||||
// TODO: Implement actual lore status check
|
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 {
|
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,
|
last_sync: None,
|
||||||
is_healthy: true,
|
is_healthy: true,
|
||||||
message: "lore integration not yet implemented".to_string(),
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,16 +2,91 @@
|
|||||||
//!
|
//!
|
||||||
//! This crate provides the Rust backend for Mission Control, handling:
|
//! This crate provides the Rust backend for Mission Control, handling:
|
||||||
//! - CLI integration with lore (GitLab data) and br (beads task management)
|
//! - 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
|
//! - Decision logging and state persistence
|
||||||
//! - File watching for automatic sync
|
//! - File watching for automatic sync
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod data;
|
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};
|
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.
|
/// Initialize the Tauri application with all plugins and commands.
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -28,7 +103,46 @@ pub fn run() {
|
|||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.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| {
|
.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)]
|
#[cfg(debug_assertions)]
|
||||||
{
|
{
|
||||||
// Open devtools in debug mode
|
// Open devtools in debug mode
|
||||||
@@ -41,6 +155,10 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::greet,
|
commands::greet,
|
||||||
commands::get_lore_status,
|
commands::get_lore_status,
|
||||||
|
commands::get_bridge_status,
|
||||||
|
commands::sync_now,
|
||||||
|
commands::reconcile,
|
||||||
|
commands::quick_capture,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -25,10 +25,6 @@
|
|||||||
"visible": true
|
"visible": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"trayIcon": {
|
|
||||||
"iconPath": "icons/icon.png",
|
|
||||||
"iconAsTemplate": true
|
|
||||||
},
|
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
}
|
}
|
||||||
@@ -39,8 +35,7 @@
|
|||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.png"
|
||||||
"icons/icon.ico"
|
|
||||||
],
|
],
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"macOS": {
|
"macOS": {
|
||||||
@@ -49,24 +44,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true,
|
"open": true
|
||||||
"scope": [
|
|
||||||
{
|
|
||||||
"name": "lore",
|
|
||||||
"cmd": "lore",
|
|
||||||
"args": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "br",
|
|
||||||
"cmd": "br",
|
|
||||||
"args": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bv",
|
|
||||||
"cmd": "bv",
|
|
||||||
"args": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user