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:
teernisse
2026-02-26 10:10:02 -05:00
parent 175c1994fc
commit 378a173084
8 changed files with 425 additions and 51 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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(())

View File

@@ -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);
}
}
}
})

View File

@@ -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);
}
}
}
}