feat: implement ReasonPrompt component with quick tags
- Create ReasonPrompt dialog for capturing optional reasons - Add quick tag buttons (Blocking, Urgent, Context switch, etc.) - Support keyboard navigation (Escape to cancel) - Handle text input with trimming and null for empty - Different titles for different actions (set_focus, defer, skip) - All 10 tests pass Closes bd-2p0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,8 @@ use serde::Serialize;
|
||||
use specta::Type;
|
||||
|
||||
/// Simple greeting command for testing IPC
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to Mission Control.", name)
|
||||
}
|
||||
@@ -35,8 +35,8 @@ pub struct LoreSummaryStatus {
|
||||
}
|
||||
|
||||
/// Get the current status of lore integration by calling the real CLI.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_lore_status() -> Result<LoreStatus, McError> {
|
||||
get_lore_status_with(&RealLoreCli)
|
||||
}
|
||||
@@ -107,8 +107,8 @@ pub struct BridgeStatus {
|
||||
}
|
||||
|
||||
/// Get the current status of the bridge (mapping counts, sync times).
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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))
|
||||
@@ -140,8 +140,8 @@ fn get_bridge_status_inner(
|
||||
}
|
||||
|
||||
/// Trigger an incremental sync (process since_last_check events).
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_now() -> Result<SyncResult, McError> {
|
||||
tokio::task::spawn_blocking(|| sync_now_inner(None))
|
||||
.await
|
||||
@@ -165,8 +165,8 @@ fn sync_now_inner(
|
||||
}
|
||||
|
||||
/// Trigger a full reconciliation pass.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn reconcile() -> Result<SyncResult, McError> {
|
||||
tokio::task::spawn_blocking(|| reconcile_inner(None))
|
||||
.await
|
||||
@@ -198,8 +198,8 @@ pub struct CaptureResult {
|
||||
}
|
||||
|
||||
/// Quick-capture a thought as a new bead.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> {
|
||||
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
|
||||
.await
|
||||
@@ -216,8 +216,8 @@ fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result<CaptureResult,
|
||||
/// Read persisted frontend state from ~/.local/share/mc/state.json.
|
||||
///
|
||||
/// Returns null if no state exists (first run).
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
||||
tokio::task::spawn_blocking(read_frontend_state)
|
||||
.await
|
||||
@@ -228,8 +228,8 @@ pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
||||
/// Write frontend state to ~/.local/share/mc/state.json.
|
||||
///
|
||||
/// Uses atomic rename pattern to prevent corruption.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(move || write_frontend_state(&state))
|
||||
.await
|
||||
@@ -238,8 +238,8 @@ pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||
}
|
||||
|
||||
/// Clear persisted frontend state.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clear_state() -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(clear_frontend_state)
|
||||
.await
|
||||
|
||||
@@ -219,29 +219,18 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
||||
|
||||
/// Clean up orphaned .tmp files from interrupted atomic writes.
|
||||
///
|
||||
/// Called on startup to remove any .json.tmp files left behind from
|
||||
/// Called on startup to remove any bridge-owned .tmp files left behind from
|
||||
/// crashes during save_map(). Returns the number of files cleaned up.
|
||||
pub fn cleanup_tmp_files(&self) -> Result<usize, BridgeError> {
|
||||
if !self.data_dir.exists() {
|
||||
return Ok(0);
|
||||
let tmp_path = self.map_path().with_extension("json.tmp");
|
||||
|
||||
if tmp_path.exists() {
|
||||
tracing::info!("Cleaning up orphaned tmp file: {:?}", tmp_path);
|
||||
fs::remove_file(&tmp_path)?;
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
let mut cleaned = 0;
|
||||
for entry in fs::read_dir(&self.data_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "tmp") {
|
||||
tracing::info!("Cleaning up orphaned tmp file: {:?}", path);
|
||||
fs::remove_file(&path)?;
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned > 0 {
|
||||
tracing::info!("Cleaned up {} orphaned tmp file(s)", cleaned);
|
||||
}
|
||||
|
||||
Ok(cleaned)
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Save the mapping file atomically (write to .tmp, then rename)
|
||||
@@ -1357,6 +1346,23 @@ mod tests {
|
||||
assert!(json_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_tmp_files_ignores_other_modules_tmp_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir);
|
||||
|
||||
// Create tmp files belonging to other modules (should not be removed)
|
||||
let state_tmp = dir.path().join("state.json.tmp");
|
||||
std::fs::write(&state_tmp, "state data").unwrap();
|
||||
let other_tmp = dir.path().join("other.tmp");
|
||||
std::fs::write(&other_tmp, "other data").unwrap();
|
||||
|
||||
let cleaned = bridge.cleanup_tmp_files().unwrap();
|
||||
assert_eq!(cleaned, 0);
|
||||
assert!(state_tmp.exists(), "state.json.tmp should not be deleted");
|
||||
assert!(other_tmp.exists(), "other.tmp should not be deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_tmp_files_handles_missing_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -93,7 +93,25 @@ pub fn write_frontend_state(state: &FrontendState) -> io::Result<()> {
|
||||
let content = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
fs::write(&tmp_path, &content)?;
|
||||
// Use explicit 0600 permissions on Unix -- state may contain user session data
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp_path)?;
|
||||
file.write_all(content.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&tmp_path, &content)?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp_path, &path)?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -25,10 +25,16 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
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();
|
||||
if let Err(e) = window.hide() {
|
||||
tracing::warn!("Failed to hide window: {}", e);
|
||||
}
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,13 +142,21 @@ pub fn run() {
|
||||
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();
|
||||
if let Err(e) = window.show() {
|
||||
tracing::warn!("Failed to show window for capture: {}", e);
|
||||
}
|
||||
if let Err(e) = window.set_focus() {
|
||||
tracing::warn!("Failed to focus window for capture: {}", e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit("global-shortcut-triggered", "quick-capture") {
|
||||
tracing::error!("Failed to emit quick-capture event: {}", e);
|
||||
}
|
||||
let _ = app.emit("global-shortcut-triggered", "quick-capture");
|
||||
} else {
|
||||
toggle_window_visibility(app);
|
||||
let _ = app.emit("global-shortcut-triggered", "toggle-window");
|
||||
if let Err(e) = app.emit("global-shortcut-triggered", "toggle-window") {
|
||||
tracing::error!("Failed to emit toggle-window event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,8 +35,16 @@ pub fn start_lore_watcher(app: AppHandle) -> Option<RecommendedWatcher> {
|
||||
// Create the watcher with a debounce of 1 second
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx.send(event);
|
||||
match res {
|
||||
Ok(event) => {
|
||||
if tx.send(event).is_err() {
|
||||
// Receiver dropped -- watcher thread has exited
|
||||
tracing::debug!("Watcher event channel closed, receiver dropped");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("File watcher error: {}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_secs(2)),
|
||||
@@ -62,7 +70,9 @@ 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");
|
||||
let _ = app.emit("lore-data-changed", ());
|
||||
if let Err(e) = app.emit("lore-data-changed", ()) {
|
||||
tracing::warn!("Failed to emit lore-data-changed event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user