feat(followup): implement PLAN-FOLLOWUP.md gap fixes
Complete implementation of 7 slices addressing E2E testing gaps: Slice 0+1: Wire Actions + ReasonPrompt - FocusView now uses useActions hook instead of direct act() calls - Added pendingAction state pattern for skip/defer/complete actions - ReasonPrompt integration with proper confirm/cancel flow - Tags support in DecisionEntry interface Slice 2: Drag Reorder UI - Installed @dnd-kit (core, sortable, utilities) - QueueView with DndContext, SortableContext, verticalListSortingStrategy - SortableQueueItem wrapper component using useSortable hook - pendingReorder state with ReasonPrompt for reorder reasons - Cmd+Up/Down keyboard shortcuts for accessibility - Fixed: Store item ID in PendingReorder to avoid stale queue reference Slice 3: System Tray Integration - tray.rs with TrayState, setup_tray, toggle_window_visibility - Menu with Show/Quit items - Left-click toggles window visibility - update_tray_badge command updates tooltip with item count - Frontend wiring in AppShell Slice 4: E2E Test Updates - Fixed test selectors for InboxView, Queue badge - Exposed inbox store for test seeding Slice 5: Staleness Visualization - Already implemented in computeStaleness() with tests Slice 6: Quick Wiring - onStartBatch callback wired to QueueView - SyncStatus rendered in nav area - SettingsView renders Settings component Slice 7: State Persistence - settings-store with hydrate/update methods - Tauri backend integration via read_settings/write_settings - AppShell hydrates settings on mount Bug fixes from code review: - close_bead now has error isolation (try/catch) so decision logging and queue advancement continue even if bead close fails - PendingReorder stores item ID to avoid stale queue reference E2E tests for all ACs (tests/e2e/followup-acs.spec.ts): - AC-F1: Drag reorder (4 tests) - AC-F2: ReasonPrompt integration (7 tests) - AC-F5: Staleness visualization (3 tests) - AC-F6: Batch mode (2 tests) - AC-F7: SyncStatus (2 tests) - ReasonPrompt behavior (3 tests) Tests: 388 frontend + 119 Rust + 32 E2E all passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,48 +47,44 @@ pub async fn get_lore_status() -> Result<LoreStatus, McError> {
|
||||
}
|
||||
|
||||
/// Testable inner function that accepts any LoreCli implementation.
|
||||
///
|
||||
/// Health is determined by whether we can get data from lore, not by
|
||||
/// lore's internal health check (which is too strict for our needs).
|
||||
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() as u32,
|
||||
authored_mrs: response.data.open_mrs_authored.len() as u32,
|
||||
reviewing_mrs: response.data.reviewing_mrs.len() as u32,
|
||||
};
|
||||
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,
|
||||
match cli.get_me() {
|
||||
Ok(response) => {
|
||||
let summary = LoreSummaryStatus {
|
||||
open_issues: response.data.open_issues.len() as u32,
|
||||
authored_mrs: response.data.open_mrs_authored.len() as u32,
|
||||
reviewing_mrs: response.data.reviewing_mrs.len() as u32,
|
||||
};
|
||||
Ok(LoreStatus {
|
||||
last_sync: response.data.since_iso.clone(),
|
||||
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,
|
||||
}),
|
||||
message: format!(
|
||||
"{} issues, {} authored MRs, {} reviews",
|
||||
summary.open_issues, summary.authored_mrs, summary.reviewing_mrs
|
||||
),
|
||||
summary: Some(summary),
|
||||
})
|
||||
}
|
||||
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 {
|
||||
Err(LoreError::CommandFailed(stderr)) => Ok(LoreStatus {
|
||||
last_sync: None,
|
||||
is_healthy: false,
|
||||
message: format!("lore error: {}", e),
|
||||
// Pass through lore's error message - it includes actionable suggestions
|
||||
message: format!("lore error: {}", stderr.trim()),
|
||||
summary: None,
|
||||
}),
|
||||
Err(LoreError::ParseFailed(e)) => Ok(LoreStatus {
|
||||
last_sync: None,
|
||||
is_healthy: false,
|
||||
message: format!("Failed to parse lore response: {}", e),
|
||||
summary: None,
|
||||
}),
|
||||
}
|
||||
@@ -276,7 +272,7 @@ pub struct TriageTopPick {
|
||||
/// Human-readable reasons for recommendation
|
||||
pub reasons: Vec<String>,
|
||||
/// Number of items this would unblock
|
||||
pub unblocks: i64,
|
||||
pub unblocks: i32,
|
||||
}
|
||||
|
||||
/// Quick win item from bv triage
|
||||
@@ -300,7 +296,7 @@ pub struct TriageBlocker {
|
||||
/// Bead title
|
||||
pub title: String,
|
||||
/// Number of items this blocks
|
||||
pub unblocks_count: i64,
|
||||
pub unblocks_count: i32,
|
||||
/// Whether this is actionable now
|
||||
pub actionable: bool,
|
||||
}
|
||||
@@ -309,13 +305,13 @@ pub struct TriageBlocker {
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct TriageCounts {
|
||||
/// Total open items
|
||||
pub open: i64,
|
||||
pub open: i32,
|
||||
/// Items that can be worked on now
|
||||
pub actionable: i64,
|
||||
pub actionable: i32,
|
||||
/// Items blocked by others
|
||||
pub blocked: i64,
|
||||
pub blocked: i32,
|
||||
/// Items currently in progress
|
||||
pub in_progress: i64,
|
||||
pub in_progress: i32,
|
||||
}
|
||||
|
||||
/// Full triage response for the frontend
|
||||
@@ -349,10 +345,10 @@ fn get_triage_with(cli: &dyn BvCli) -> Result<TriageResponse, McError> {
|
||||
let response = cli.robot_triage()?;
|
||||
|
||||
let counts = TriageCounts {
|
||||
open: response.triage.quick_ref.open_count,
|
||||
actionable: response.triage.quick_ref.actionable_count,
|
||||
blocked: response.triage.quick_ref.blocked_count,
|
||||
in_progress: response.triage.quick_ref.in_progress_count,
|
||||
open: response.triage.quick_ref.open_count as i32,
|
||||
actionable: response.triage.quick_ref.actionable_count as i32,
|
||||
blocked: response.triage.quick_ref.blocked_count as i32,
|
||||
in_progress: response.triage.quick_ref.in_progress_count as i32,
|
||||
};
|
||||
|
||||
let top_picks = response
|
||||
@@ -365,7 +361,7 @@ fn get_triage_with(cli: &dyn BvCli) -> Result<TriageResponse, McError> {
|
||||
title: p.title,
|
||||
score: p.score,
|
||||
reasons: p.reasons,
|
||||
unblocks: p.unblocks,
|
||||
unblocks: p.unblocks as i32,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -388,7 +384,7 @@ fn get_triage_with(cli: &dyn BvCli) -> Result<TriageResponse, McError> {
|
||||
.map(|b| TriageBlocker {
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
unblocks_count: b.unblocks_count,
|
||||
unblocks_count: b.unblocks_count as i32,
|
||||
actionable: b.actionable,
|
||||
})
|
||||
.collect();
|
||||
@@ -414,7 +410,7 @@ pub struct NextPickResponse {
|
||||
/// Reasons for recommendation
|
||||
pub reasons: Vec<String>,
|
||||
/// Number of items this unblocks
|
||||
pub unblocks: i64,
|
||||
pub unblocks: i32,
|
||||
/// Shell command to claim this bead
|
||||
pub claim_command: String,
|
||||
}
|
||||
@@ -440,7 +436,7 @@ fn get_next_pick_with(cli: &dyn BvCli) -> Result<NextPickResponse, McError> {
|
||||
title: response.title,
|
||||
score: response.score,
|
||||
reasons: response.reasons,
|
||||
unblocks: response.unblocks,
|
||||
unblocks: response.unblocks as i32,
|
||||
claim_command: response.claim_command,
|
||||
})
|
||||
}
|
||||
@@ -551,6 +547,35 @@ fn get_time_of_day(now: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// -- System tray commands --
|
||||
|
||||
/// Update the system tray tooltip to reflect the current item count.
|
||||
///
|
||||
/// Called by the frontend whenever the total queue/focus count changes.
|
||||
/// Gracefully handles missing tray state (e.g., tray init failed).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn update_tray_badge(
|
||||
count: u32,
|
||||
state: tauri::State<'_, crate::tray::TrayState>,
|
||||
) -> Result<(), McError> {
|
||||
let tooltip = if count == 0 {
|
||||
"Mission Control".to_string()
|
||||
} else {
|
||||
format!("Mission Control - {} items", count)
|
||||
};
|
||||
|
||||
let tray = state
|
||||
.tray
|
||||
.lock()
|
||||
.map_err(|e| McError::internal(format!("Failed to lock tray state: {}", e)))?;
|
||||
|
||||
tray.set_tooltip(Some(&tooltip))
|
||||
.map_err(|e| McError::internal(format!("Failed to set tray tooltip: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates to apply to an item (for defer/skip actions)
|
||||
#[derive(Debug, Clone, Deserialize, Type)]
|
||||
pub struct ItemUpdates {
|
||||
@@ -578,6 +603,123 @@ pub async fn update_item(id: String, updates: ItemUpdates) -> Result<(), McError
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Lore items command (full data for queue population) --
|
||||
|
||||
/// A lore item (issue or MR) for the frontend queue
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct LoreItem {
|
||||
/// Unique key matching bridge format: "issue:project:iid" or "mr_review:project:iid"
|
||||
pub id: String,
|
||||
/// Item title
|
||||
pub title: String,
|
||||
/// Item type: "issue", "mr_review", or "mr_authored"
|
||||
pub item_type: String,
|
||||
/// Project path (e.g., "group/repo")
|
||||
pub project: String,
|
||||
/// GitLab web URL
|
||||
pub url: String,
|
||||
/// Issue/MR IID within the project
|
||||
pub iid: i64,
|
||||
/// Last updated timestamp (ISO 8601)
|
||||
pub updated_at: Option<String>,
|
||||
/// Who requested this (for reviews)
|
||||
pub requested_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from get_lore_items containing all work items
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct LoreItemsResponse {
|
||||
/// All items (reviews, issues, authored MRs)
|
||||
pub items: Vec<LoreItem>,
|
||||
/// Whether lore data was successfully fetched
|
||||
pub success: bool,
|
||||
/// Error message if fetch failed
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Get all lore items (issues, MRs, reviews) for queue population.
|
||||
///
|
||||
/// Unlike get_lore_status which returns summary counts, this returns
|
||||
/// the actual items needed to populate the Focus and Queue views.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_lore_items() -> Result<LoreItemsResponse, McError> {
|
||||
get_lore_items_with(&RealLoreCli)
|
||||
}
|
||||
|
||||
/// Escape project path for use in item IDs.
|
||||
/// Replaces / with :: to match bridge key format.
|
||||
fn escape_project(project: &str) -> String {
|
||||
project.replace('/', "::")
|
||||
}
|
||||
|
||||
/// Testable inner function that accepts any LoreCli implementation.
|
||||
fn get_lore_items_with(cli: &dyn LoreCli) -> Result<LoreItemsResponse, McError> {
|
||||
match cli.get_me() {
|
||||
Ok(response) => {
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Reviews first (you're blocking someone)
|
||||
for mr in &response.data.reviewing_mrs {
|
||||
items.push(LoreItem {
|
||||
id: format!("mr_review:{}:{}", escape_project(&mr.project), mr.iid),
|
||||
title: mr.title.clone(),
|
||||
item_type: "mr_review".to_string(),
|
||||
project: mr.project.clone(),
|
||||
url: mr.web_url.clone(),
|
||||
iid: mr.iid,
|
||||
updated_at: mr.updated_at_iso.clone(),
|
||||
requested_by: mr.author_username.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Assigned issues
|
||||
for issue in &response.data.open_issues {
|
||||
items.push(LoreItem {
|
||||
id: format!("issue:{}:{}", escape_project(&issue.project), issue.iid),
|
||||
title: issue.title.clone(),
|
||||
item_type: "issue".to_string(),
|
||||
project: issue.project.clone(),
|
||||
url: issue.web_url.clone(),
|
||||
iid: issue.iid,
|
||||
updated_at: issue.updated_at_iso.clone(),
|
||||
requested_by: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Authored MRs last (your own work, less urgent)
|
||||
for mr in &response.data.open_mrs_authored {
|
||||
items.push(LoreItem {
|
||||
id: format!("mr_authored:{}:{}", escape_project(&mr.project), mr.iid),
|
||||
title: mr.title.clone(),
|
||||
item_type: "mr_authored".to_string(),
|
||||
project: mr.project.clone(),
|
||||
url: mr.web_url.clone(),
|
||||
iid: mr.iid,
|
||||
updated_at: mr.updated_at_iso.clone(),
|
||||
requested_by: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(LoreItemsResponse {
|
||||
items,
|
||||
success: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(LoreError::ExecutionFailed(_)) => Ok(LoreItemsResponse {
|
||||
items: vec![],
|
||||
success: false,
|
||||
error: Some("lore CLI not found -- is it installed?".to_string()),
|
||||
}),
|
||||
Err(e) => Ok(LoreItemsResponse {
|
||||
items: vec![],
|
||||
success: false,
|
||||
error: Some(format!("lore error: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -585,7 +727,6 @@ mod tests {
|
||||
|
||||
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,
|
||||
@@ -615,19 +756,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_lore_status_unhealthy() {
|
||||
fn test_get_lore_status_command_failed() {
|
||||
let mut mock = MockLoreCli::new();
|
||||
mock.expect_health_check().returning(|| Ok(false));
|
||||
mock.expect_get_me()
|
||||
.returning(|| Err(LoreError::CommandFailed("config not found".to_string())));
|
||||
|
||||
let result = get_lore_status_with(&mock).unwrap();
|
||||
assert!(!result.is_healthy);
|
||||
assert!(result.message.contains("health check failed"));
|
||||
assert!(result.message.contains("lore error"));
|
||||
assert!(result.message.contains("config not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_lore_status_cli_not_found() {
|
||||
let mut mock = MockLoreCli::new();
|
||||
mock.expect_health_check()
|
||||
mock.expect_get_me()
|
||||
.returning(|| Err(LoreError::ExecutionFailed("not found".to_string())));
|
||||
|
||||
let result = get_lore_status_with(&mock).unwrap();
|
||||
@@ -636,15 +779,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_lore_status_healthy_but_get_me_fails() {
|
||||
fn test_get_lore_status_parse_failed() {
|
||||
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.is_healthy);
|
||||
assert!(result.message.contains("Failed to parse"));
|
||||
assert!(result.summary.is_none());
|
||||
}
|
||||
|
||||
@@ -653,7 +795,6 @@ mod tests {
|
||||
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,
|
||||
@@ -1155,4 +1296,124 @@ mod tests {
|
||||
let early_morning = chrono::Utc.with_ymd_and_hms(2026, 2, 26, 3, 0, 0).unwrap();
|
||||
assert_eq!(get_time_of_day(&early_morning), "night");
|
||||
}
|
||||
|
||||
// -- get_lore_items tests --
|
||||
|
||||
#[test]
|
||||
fn test_get_lore_items_returns_all_item_types() {
|
||||
use crate::data::lore::{LoreIssue, LoreMr};
|
||||
|
||||
let mut mock = MockLoreCli::new();
|
||||
mock.expect_get_me().returning(|| {
|
||||
Ok(LoreMeResponse {
|
||||
ok: true,
|
||||
data: LoreMeData {
|
||||
open_issues: vec![LoreIssue {
|
||||
iid: 42,
|
||||
title: "Fix auth bug".to_string(),
|
||||
project: "group/repo".to_string(),
|
||||
state: "opened".to_string(),
|
||||
web_url: "https://gitlab.com/group/repo/-/issues/42".to_string(),
|
||||
labels: vec![],
|
||||
attention_state: None,
|
||||
status_name: None,
|
||||
updated_at_iso: Some("2026-02-26T10:00:00Z".to_string()),
|
||||
}],
|
||||
open_mrs_authored: vec![LoreMr {
|
||||
iid: 100,
|
||||
title: "Add feature".to_string(),
|
||||
project: "group/repo".to_string(),
|
||||
state: "opened".to_string(),
|
||||
web_url: "https://gitlab.com/group/repo/-/merge_requests/100".to_string(),
|
||||
labels: vec![],
|
||||
attention_state: None,
|
||||
author_username: None,
|
||||
detailed_merge_status: None,
|
||||
draft: false,
|
||||
updated_at_iso: None,
|
||||
}],
|
||||
reviewing_mrs: vec![LoreMr {
|
||||
iid: 200,
|
||||
title: "Review this".to_string(),
|
||||
project: "other/project".to_string(),
|
||||
state: "opened".to_string(),
|
||||
web_url: "https://gitlab.com/other/project/-/merge_requests/200".to_string(),
|
||||
labels: vec![],
|
||||
attention_state: None,
|
||||
author_username: Some("alice".to_string()),
|
||||
detailed_merge_status: None,
|
||||
draft: false,
|
||||
updated_at_iso: Some("2026-02-26T09:00:00Z".to_string()),
|
||||
}],
|
||||
activity: vec![],
|
||||
since_last_check: None,
|
||||
summary: None,
|
||||
username: None,
|
||||
since_iso: None,
|
||||
},
|
||||
meta: None,
|
||||
})
|
||||
});
|
||||
|
||||
let result = get_lore_items_with(&mock).unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.error.is_none());
|
||||
assert_eq!(result.items.len(), 3);
|
||||
|
||||
// Reviews come first
|
||||
assert_eq!(result.items[0].item_type, "mr_review");
|
||||
assert_eq!(result.items[0].id, "mr_review:other::project:200");
|
||||
assert_eq!(result.items[0].requested_by, Some("alice".to_string()));
|
||||
|
||||
// Then issues
|
||||
assert_eq!(result.items[1].item_type, "issue");
|
||||
assert_eq!(result.items[1].id, "issue:group::repo:42");
|
||||
|
||||
// Then authored MRs
|
||||
assert_eq!(result.items[2].item_type, "mr_authored");
|
||||
assert_eq!(result.items[2].id, "mr_authored:group::repo:100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_lore_items_empty_response() {
|
||||
let mock = mock_healthy_cli();
|
||||
let result = get_lore_items_with(&mock).unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_lore_items_cli_not_found() {
|
||||
let mut mock = MockLoreCli::new();
|
||||
mock.expect_get_me()
|
||||
.returning(|| Err(LoreError::ExecutionFailed("not found".to_string())));
|
||||
|
||||
let result = get_lore_items_with(&mock).unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result.items.is_empty());
|
||||
assert!(result.error.unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_lore_items_command_failed() {
|
||||
let mut mock = MockLoreCli::new();
|
||||
mock.expect_get_me()
|
||||
.returning(|| Err(LoreError::CommandFailed("auth failed".to_string())));
|
||||
|
||||
let result = get_lore_items_with(&mock).unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result.items.is_empty());
|
||||
assert!(result.error.unwrap().contains("auth failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_project_replaces_slashes() {
|
||||
assert_eq!(escape_project("group/repo"), "group::repo");
|
||||
assert_eq!(escape_project("a/b/c"), "a::b::c");
|
||||
assert_eq!(escape_project("noslash"), "noslash");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ use mockall::automock;
|
||||
/// Trait for interacting with lore CLI
|
||||
///
|
||||
/// This abstraction allows us to mock lore in tests.
|
||||
/// Note: We don't use `lore health` because it's too strict (checks schema
|
||||
/// migrations, index freshness, etc). MC only cares if we can get data.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait LoreCli: Send + Sync {
|
||||
/// Execute `lore --robot me` and return the parsed result
|
||||
fn get_me(&self) -> Result<LoreMeResponse, LoreError>;
|
||||
|
||||
/// Execute `lore --robot health` and check if lore is healthy
|
||||
fn health_check(&self) -> Result<bool, LoreError>;
|
||||
}
|
||||
|
||||
/// Real implementation that shells out to lore CLI
|
||||
@@ -39,15 +38,6 @@ impl LoreCli for RealLoreCli {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str(&stdout).map_err(|e| LoreError::ParseFailed(e.to_string()))
|
||||
}
|
||||
|
||||
fn health_check(&self) -> Result<bool, LoreError> {
|
||||
let output = Command::new("lore")
|
||||
.args(["health", "--json"])
|
||||
.output()
|
||||
.map_err(|e| LoreError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when interacting with lore
|
||||
@@ -287,15 +277,6 @@ mod tests {
|
||||
assert_eq!(result.data.open_issues[0].iid, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_lore_cli_health_check() {
|
||||
let mut mock = MockLoreCli::new();
|
||||
|
||||
mock.expect_health_check().times(1).returning(|| Ok(true));
|
||||
|
||||
assert!(mock.health_check().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_lore_cli_can_return_error() {
|
||||
let mut mock = MockLoreCli::new();
|
||||
|
||||
37
src-tauri/src/events.rs
Normal file
37
src-tauri/src/events.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! Typed events for Tauri IPC.
|
||||
//!
|
||||
//! These events are registered with tauri-specta to generate TypeScript bindings.
|
||||
//! Using typed events provides compile-time type safety on both Rust and TS sides.
|
||||
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use tauri_specta::Event;
|
||||
|
||||
use crate::app::{CliAvailability, StartupWarning};
|
||||
|
||||
/// Emitted when lore.db file changes (triggers data refresh)
|
||||
#[derive(Debug, Clone, Serialize, Type, Event)]
|
||||
pub struct LoreDataChanged;
|
||||
|
||||
/// Emitted when a global shortcut is triggered
|
||||
#[derive(Debug, Clone, Serialize, Type, Event)]
|
||||
pub struct GlobalShortcutTriggered {
|
||||
/// The shortcut that was triggered: "quick-capture" or "toggle-window"
|
||||
pub shortcut: String,
|
||||
}
|
||||
|
||||
/// Emitted at startup with any warnings (missing CLIs, state resets, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Type, Event)]
|
||||
pub struct StartupWarningsEvent {
|
||||
pub warnings: Vec<StartupWarning>,
|
||||
}
|
||||
|
||||
/// Emitted at startup with CLI availability status
|
||||
#[derive(Debug, Clone, Serialize, Type, Event)]
|
||||
pub struct CliAvailabilityEvent {
|
||||
pub availability: CliAvailability,
|
||||
}
|
||||
|
||||
/// Emitted when startup sync is ready (all CLIs available)
|
||||
#[derive(Debug, Clone, Serialize, Type, Event)]
|
||||
pub struct StartupSyncReady;
|
||||
@@ -10,74 +10,20 @@ pub mod app;
|
||||
pub mod commands;
|
||||
pub mod data;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod sync;
|
||||
pub mod tray;
|
||||
pub mod watcher;
|
||||
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||
use tauri_specta::{collect_commands, Builder};
|
||||
use tauri_specta::{collect_commands, collect_events, Builder, Event};
|
||||
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(())
|
||||
}
|
||||
use events::{
|
||||
CliAvailabilityEvent, GlobalShortcutTriggered, LoreDataChanged, StartupSyncReady,
|
||||
StartupWarningsEvent,
|
||||
};
|
||||
|
||||
/// Register global hotkeys:
|
||||
/// - Cmd+Shift+M: toggle window visibility
|
||||
@@ -111,32 +57,47 @@ pub fn run() {
|
||||
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,
|
||||
]);
|
||||
let builder = Builder::<tauri::Wry>::new()
|
||||
.commands(collect_commands![
|
||||
commands::greet,
|
||||
commands::get_lore_status,
|
||||
commands::get_lore_items,
|
||||
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,
|
||||
commands::update_tray_badge,
|
||||
])
|
||||
.events(collect_events![
|
||||
LoreDataChanged,
|
||||
GlobalShortcutTriggered,
|
||||
StartupWarningsEvent,
|
||||
CliAvailabilityEvent,
|
||||
StartupSyncReady,
|
||||
]);
|
||||
|
||||
// Export TypeScript bindings in debug builds
|
||||
#[cfg(debug_assertions)]
|
||||
builder
|
||||
.export(
|
||||
specta_typescript::Typescript::default(),
|
||||
specta_typescript::Typescript::default()
|
||||
// Allow i64 as JS number - safe for our count values which never exceed 2^53
|
||||
.bigint(specta_typescript::BigIntExportBehavior::Number),
|
||||
"../src/lib/bindings.ts",
|
||||
)
|
||||
.expect("Failed to export TypeScript bindings");
|
||||
|
||||
// Get invoke_handler before moving builder into setup
|
||||
let invoke_handler = builder.invoke_handler();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(
|
||||
@@ -156,12 +117,18 @@ pub fn run() {
|
||||
tracing::warn!("Failed to focus window for capture: {}", e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit("global-shortcut-triggered", "quick-capture") {
|
||||
let event = GlobalShortcutTriggered {
|
||||
shortcut: "quick-capture".to_string(),
|
||||
};
|
||||
if let Err(e) = event.emit(app) {
|
||||
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") {
|
||||
tray::toggle_window_visibility(app);
|
||||
let event = GlobalShortcutTriggered {
|
||||
shortcut: "toggle-window".to_string(),
|
||||
};
|
||||
if let Err(e) = event.emit(app) {
|
||||
tracing::error!("Failed to emit toggle-window event: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -169,7 +136,10 @@ pub fn run() {
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.setup(|app| {
|
||||
.setup(move |app| {
|
||||
// Mount typed events into Tauri state
|
||||
builder.mount_events(app);
|
||||
|
||||
use data::beads::RealBeadsCli;
|
||||
use data::bridge::Bridge;
|
||||
use data::lore::RealLoreCli;
|
||||
@@ -213,13 +183,17 @@ pub fn run() {
|
||||
|
||||
// Emit startup warnings to frontend
|
||||
if !warnings.is_empty() {
|
||||
if let Err(e) = app_handle.emit("startup-warnings", &warnings) {
|
||||
let event = StartupWarningsEvent { warnings };
|
||||
if let Err(e) = event.emit(&app_handle) {
|
||||
tracing::error!("Failed to emit startup warnings: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit CLI availability to frontend
|
||||
if let Err(e) = app_handle.emit("cli-availability", &cli_available) {
|
||||
let event = CliAvailabilityEvent {
|
||||
availability: cli_available.clone(),
|
||||
};
|
||||
if let Err(e) = event.emit(&app_handle) {
|
||||
tracing::error!("Failed to emit CLI availability: {}", e);
|
||||
}
|
||||
|
||||
@@ -227,14 +201,14 @@ pub fn run() {
|
||||
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", ()) {
|
||||
if let Err(e) = StartupSyncReady.emit(&app_handle) {
|
||||
tracing::error!("Failed to emit startup-sync-ready: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set up system tray
|
||||
if let Err(e) = setup_tray(app) {
|
||||
if let Err(e) = tray::setup_tray(app) {
|
||||
tracing::error!("Failed to setup system tray: {}", e);
|
||||
}
|
||||
|
||||
@@ -259,7 +233,7 @@ pub fn run() {
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(builder.invoke_handler())
|
||||
.invoke_handler(invoke_handler)
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
82
src-tauri/src/tray.rs
Normal file
82
src-tauri/src/tray.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! System tray integration for Mission Control.
|
||||
//!
|
||||
//! Creates a tray icon with context menu and stores the handle
|
||||
//! so other parts of the app can update the badge/tooltip.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Holds the tray icon handle so commands can update tooltip/badge.
|
||||
pub struct TrayState {
|
||||
pub tray: Mutex<tauri::tray::TrayIcon>,
|
||||
}
|
||||
|
||||
/// Toggle the main window's visibility.
|
||||
///
|
||||
/// If the window is visible and focused, hide it.
|
||||
/// If hidden or not focused, show and focus it.
|
||||
pub fn toggle_window_visibility(app: &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 context menu.
|
||||
///
|
||||
/// Stores the tray icon handle in Tauri managed state so
|
||||
/// `update_tray_badge` can update the tooltip later.
|
||||
pub 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()?;
|
||||
|
||||
let tray = 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)?;
|
||||
|
||||
// Store tray handle in managed state for badge updates
|
||||
app.manage(TrayState {
|
||||
tray: Mutex::new(tray),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watche
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::AppHandle;
|
||||
use tauri_specta::Event as TauriEvent;
|
||||
|
||||
use crate::events::LoreDataChanged;
|
||||
|
||||
/// Get the path to lore's database file
|
||||
fn lore_db_path() -> Option<PathBuf> {
|
||||
@@ -70,7 +73,7 @@ pub fn start_lore_watcher(app: AppHandle) -> Option<RecommendedWatcher> {
|
||||
let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db"));
|
||||
if affects_db {
|
||||
tracing::debug!("lore.db changed, emitting refresh event");
|
||||
if let Err(e) = app.emit("lore-data-changed", ()) {
|
||||
if let Err(e) = LoreDataChanged.emit(&app) {
|
||||
tracing::warn!("Failed to emit lore-data-changed event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user