diff options
Diffstat (limited to 'src/app/api.rs')
| -rw-r--r-- | src/app/api.rs | 301 |
1 files changed, 299 insertions, 2 deletions
diff --git a/src/app/api.rs b/src/app/api.rs index 592ee7da..4bb89a77 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -12,6 +12,8 @@ mod worktrees; use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; +const API_NOTIFICATION_RATE_LIMIT: Duration = Duration::from_secs(1); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuntimeExitAction { RespawnShell, @@ -154,7 +156,7 @@ impl App { if let Some((version, install_command)) = update_ready { let instruction = crate::update::update_install_instruction(&install_command); let _ = notify(&format!("v{version} available"), Some(&instruction)); - } else { + } else if self.state.toast_config.delay_seconds == 0 { for update in &pane_updates { let is_active_tab = self .state @@ -257,6 +259,11 @@ impl App { } pub(crate) fn show_clipboard_feedback(&mut self) { + if !self.state.toast_config.clipboard.enabled { + self.state.copy_feedback = None; + self.copy_feedback_deadline = None; + return; + } self.state.copy_feedback = Some(crate::app::state::CopyFeedback { message: "copied to clipboard".to_string(), }); @@ -396,7 +403,7 @@ impl App { } } - pub(super) fn sync_toast_deadline( + pub(crate) fn sync_toast_deadline( &mut self, previous_toast: Option<crate::app::state::ToastNotification>, ) { @@ -412,6 +419,72 @@ impl App { } } + pub(crate) fn emit_delayed_client_local_agent_notifications( + &self, + deliveries: &[crate::app::state::AgentNotificationDelivery], + ) { + if !self.local_terminal_notifications + || !matches!( + self.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System + ) + { + return; + } + + let notify = match self.state.toast_config.delivery { + crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, + crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, + _ => unreachable!("toast delivery was checked above"), + }; + + for delivery in deliveries { + let Some(toast) = &delivery.client_notification else { + continue; + }; + let _ = notify(&toast.title, Some(&toast.context)); + } + } + + pub(crate) fn refresh_agent_notification_delivery_contexts( + &mut self, + deliveries: &mut [crate::app::state::AgentNotificationDelivery], + ) { + for delivery in deliveries { + let Some(ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == delivery.workspace_id) + else { + continue; + }; + let ws = &self.state.workspaces[ws_idx]; + let workspace_label = + ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); + let context = crate::app::actions::notification_context( + ws, + &workspace_label, + ws_idx, + delivery.pane_id, + ); + if let Some(toast) = delivery.toast.as_mut() { + toast.context = context.clone(); + } + if let Some(toast) = delivery.client_notification.as_mut() { + toast.context = context.clone(); + } + if let Some(toast) = self.state.toast.as_mut() { + if toast.target.as_ref().is_some_and(|target| { + target.workspace_id == delivery.workspace_id + && target.pane_id == delivery.pane_id + }) { + toast.context = context; + } + } + } + } + pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) { self.event_hub.push(event); } @@ -519,6 +592,9 @@ impl App { }, } } + Method::NotificationShow(params) => { + return self.handle_notification_show(request.id, params); + } Method::WorkspaceList(_) => return self.handle_workspace_list(request.id), Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target), Method::WorkspaceCreate(params) => { @@ -606,6 +682,128 @@ impl App { serde_json::to_string(&response).unwrap() } + + fn handle_notification_show( + &mut self, + id: String, + params: crate::api::schema::NotificationShowParams, + ) -> String { + use crate::api::schema::{NotificationShowReason, ResponseResult}; + + let requested_sound = params.sound; + let Some(title) = sanitized_notification_text(¶ms.title, 80) else { + return responses::encode_error(id, "invalid_params", "notification title is empty"); + }; + let body = params + .body + .as_deref() + .and_then(|body| sanitized_notification_text(body, 240)); + + let reason = match self.state.toast_config.delivery { + crate::config::ToastDelivery::Off => NotificationShowReason::Disabled, + crate::config::ToastDelivery::Herdr => { + if self.state.toast.is_some() { + NotificationShowReason::Busy + } else if self.api_notification_rate_limited(Instant::now()) { + NotificationShowReason::RateLimited + } else { + let previous_toast = self.state.toast.clone(); + self.mark_api_notification_shown(Instant::now()); + self.state.toast = Some(crate::app::state::ToastNotification { + kind: ToastKind::UpdateInstalled, + title, + context: body.unwrap_or_default(), + position: params.position, + target: None, + }); + self.sync_toast_deadline(previous_toast); + self.emit_api_notification_sound(requested_sound); + NotificationShowReason::Shown + } + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => { + if self.api_notification_rate_limited(Instant::now()) { + NotificationShowReason::RateLimited + } else { + let notify = match self.state.toast_config.delivery { + crate::config::ToastDelivery::Terminal => { + crate::terminal_notify::show_notification + } + crate::config::ToastDelivery::System => { + crate::platform::show_desktop_notification + } + _ => unreachable!("notification delivery was checked above"), + }; + match notify(&title, body.as_deref()) { + Ok(true) => { + self.mark_api_notification_shown(Instant::now()); + self.emit_api_notification_sound(requested_sound); + NotificationShowReason::Shown + } + Ok(false) | Err(_) => NotificationShowReason::NoForegroundClient, + } + } + } + }; + + responses::encode_success( + id, + ResponseResult::NotificationShow { + shown: matches!(reason, NotificationShowReason::Shown), + reason, + }, + ) + } + + fn emit_api_notification_sound(&self, sound: crate::api::schema::NotificationShowSound) { + if !self.state.local_sound_playback || !self.state.sound.allows(None) { + return; + } + if let Some(sound) = sound.to_sound() { + crate::sound::play(sound, &self.state.sound); + } + } + + pub(crate) fn api_notification_rate_limited(&self, now: Instant) -> bool { + self.last_api_notification_at + .is_some_and(|last| now.duration_since(last) < API_NOTIFICATION_RATE_LIMIT) + } + + pub(crate) fn mark_api_notification_shown(&mut self, now: Instant) { + self.last_api_notification_at = Some(now); + } +} + +fn sanitized_notification_text(value: &str, max_chars: usize) -> Option<String> { + let mut sanitized = String::new(); + let mut previous_space = false; + for ch in value.chars() { + let replacement = if ch == '\n' || ch == '\r' || ch == '\t' { + Some(' ') + } else if ch.is_control() { + None + } else { + Some(ch) + }; + let Some(ch) = replacement else { + continue; + }; + if ch.is_whitespace() { + if previous_space { + continue; + } + previous_space = true; + sanitized.push(' '); + } else { + previous_space = false; + sanitized.push(ch); + } + if sanitized.chars().count() >= max_chars { + break; + } + } + let sanitized = sanitized.trim().to_string(); + (!sanitized.is_empty()).then_some(sanitized) } #[cfg(test)] @@ -660,6 +858,7 @@ mod tests { app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.toast_config.delay_seconds = 0; let (events, _) = tokio::sync::mpsc::channel(4); let runtime = crate::terminal::TerminalRuntime::spawn( @@ -714,6 +913,103 @@ mod tests { } #[tokio::test] + async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + + let mut workspace = crate::workspace::Workspace::test_new("stale"); + workspace.custom_name = None; + let root = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(root).cloned().unwrap(); + let temp_root = std::env::temp_dir().join(format!( + "herdr-delayed-toast-context-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let stale_cwd = temp_root.join("__herdr_original__"); + let live_cwd = temp_root.join("__herdr_projects__"); + std::fs::create_dir_all(&stale_cwd).unwrap(); + std::fs::create_dir_all(&live_cwd).unwrap(); + init_repo(&stale_cwd); + init_repo(&live_cwd); + + workspace.identity_cwd = stale_cwd.clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; + app.state.active = None; + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.toast_config.delay_seconds = 1; + + let (events, _) = tokio::sync::mpsc::channel(4); + let runtime = crate::terminal::TerminalRuntime::spawn( + root, + 24, + 80, + live_cwd.clone(), + 0, + crate::terminal_theme::TerminalTheme::default(), + crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + events, + std::sync::Arc::new(tokio::sync::Notify::new()), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + app.terminal_runtimes.insert(terminal_id, runtime); + + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + let notification_deadline = app + .state + .next_pending_agent_notification_deadline() + .expect("pending notification deadline"); + assert!(app.handle_scheduled_tasks(notification_deadline, false)); + assert_eq!( + app.state.toast.as_ref().map(|toast| toast.context.as_str()), + Some("__herdr_projects__ · 1") + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(temp_root); + } + + #[tokio::test] async fn pane_died_respawns_shell_and_clears_restored_agent_session() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( @@ -802,6 +1098,7 @@ mod tests { kind: ToastKind::Finished, title: "codex finished".into(), context: "__herdr_original__ · 1".into(), + position: None, target: Some(crate::app::state::ToastTarget { workspace_id, pane_id: root, |
