From cdee9da60989b68d9fbf863acefaefca5b20edb4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:11:28 +0800 Subject: [PATCH] Honor bread.command.search.open via breadsearch listen --- EVENTS.md | 44 +++++++++++++++----- breadsearch/src/listen.rs | 84 +++++++++++++++++++++++++++++++++++++++ breadsearch/src/main.rs | 6 +++ 3 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 breadsearch/src/listen.rs diff --git a/EVENTS.md b/EVENTS.md index dbcaa54..61f5030 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -10,8 +10,10 @@ convention this follows. App id: **`search`**. Transport: `bread-utils`'s `bread_client` module (feature `bread-client`) — the overlay links it directly. Each `emit` is its own short-lived connection (`BreadClient::emit` is fire-and-forget, -the same stance as `bread-emit`). breadmill, the indexing daemon, does -not talk to breadd. +the same stance as `bread-emit`). Command verbs are only received while +`breadsearch listen` is running — that process holds the +`bread.command.search.**` subscription open. breadmill, the indexing +daemon, does not talk to breadd. ## Events published (`bread.search.*`) @@ -19,18 +21,42 @@ not talk to breadd. |-------|------|------| | `bread.search.opened` | `{}` | The overlay window maps (the search panel is shown). | | `bread.search.opened_result` | `{ "path": "" }` | The user opens a hit — Enter / click opens the file, Ctrl+Enter reveals its folder. `path` is the hit's document path, not the parent folder. | +| `bread.search.open.done` | `{}` | `bread.command.search.open` was received and `breadsearch` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same PID-file toggle as a keybind. | +| `bread.search.open.failed` | `{ "error": "" }` | `bread.command.search.open` was received but this binary could not be started. | ## Commands honored (`bread.command.search.*`) -None. The overlay is a short-lived toggle process with no existing command -surface (no show/hide/query IPC beyond the PID-file toggle and breadmill's -own query socket). Adding verbs would mean inventing a control plane that -does not exist; if/when breadsearch grows one, the corresponding -`bread.command.search.*` verbs should be added at the same time, not stubbed -out ahead of it. +These are only received while `breadsearch listen` is running. Publishing a +command with no subscriber is a silent no-op — that is the documented +bread convention, not a breadsearch bug. + +| Verb | Data | Effect | +|------|------|--------| +| `open` | none | Same as running `breadsearch` (PID-file toggle: show the overlay, or dismiss it if it is already up). Emits `bread.search.open.done` / `.failed`. | + +```lua +bread.spawn(function() + bread.emit("bread.command.search.open") + bread.wait("bread.search.open.done", { timeout = 5000 }) +end) +``` + +### Not implemented: extra verbs + +There is no `query` / `close` / `reindex` command verb. breadmill already +has its own query socket; inventing a bus query plane would be a new +product surface. If/when that exists, add the corresponding +`bread.command.search.*` verb at the same time, not stubbed as a no-op +ahead of it. ## Fail-safe behavior - If breadd isn't installed or isn't running, `emit` is a silent no-op - (`BreadClient::emit` never blocks or errors the caller) — breadsearch's + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything — breadsearch's overlay and breadmill's indexing/query path are entirely unaffected. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff + loop); no restart of `breadsearch listen` is needed. +- If `breadsearch listen` is not running, commands are a graceful no-op at + the bus (no subscriber). The overlay CLI still works. diff --git a/breadsearch/src/listen.rs b/breadsearch/src/listen.rs new file mode 100644 index 0000000..aef9c84 --- /dev/null +++ b/breadsearch/src/listen.rs @@ -0,0 +1,84 @@ +//! Long-running command subscription for `bread.command.search.*`. +//! +//! `breadsearch` is still a one-shot toggle overlay by default. +//! `breadsearch listen` is the optional persistent process that can honor +//! bus commands. See `EVENTS.md`. + +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +use crate::bread_events::APP_ID; + +/// Subscribe to `bread.command.search.**` and block until the process is killed. +/// +/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects +/// with backoff, and `on_event` simply isn't called until the daemon is up. +pub fn run() { + let client = BreadClient::connect(APP_ID); + if client.health().is_none() { + eprintln!( + "breadsearch: breadd unreachable; command subscription will connect when it comes back" + ); + } + + let _commands = client.subscribe("bread.command.search.**", |event| { + handle_command(&event); + }); + + eprintln!("breadsearch: listening for bread.command.search.**"); + loop { + std::thread::park(); + } +} + +/// Reacts to `bread.command.search.*` verbs. Only `open` is honored today — +/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. +fn handle_command(event: &BreadEvent) { + let Some(verb) = command_verb(&event.event) else { + return; + }; + match verb { + "open" => handle_open(), + other => { + eprintln!("breadsearch: ignoring unrecognized bread.command.search.{other}"); + } + } +} + +fn handle_open() { + // Same as running `breadsearch` from a keybind: the PID-file toggle + // shows the overlay (or dismisses it if it is already up). + let result = spawn_self(); + let client = BreadClient::connect(APP_ID); + match result { + Ok(_) => client.emit("bread.search.open.done", serde_json::json!({})), + Err(e) => { + eprintln!("breadsearch: bread.command.search.open failed: {e}"); + client.emit( + "bread.search.open.failed", + serde_json::json!({ "error": e.to_string() }), + ); + } + } +} + +fn spawn_self() -> std::io::Result { + let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadsearch")); + std::process::Command::new(exe).spawn() +} + +fn command_verb(event_name: &str) -> Option<&str> { + event_name.strip_prefix("bread.command.search.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_verb_strips_search_prefix() { + assert_eq!(command_verb("bread.command.search.open"), Some("open")); + assert_eq!(command_verb("bread.command.search.query"), Some("query")); + assert_eq!(command_verb("bread.command.box.open"), None); + assert_eq!(command_verb("bread.search.opened"), None); + } +} diff --git a/breadsearch/src/main.rs b/breadsearch/src/main.rs index 6f55769..247ad80 100644 --- a/breadsearch/src/main.rs +++ b/breadsearch/src/main.rs @@ -19,6 +19,7 @@ use gtk4::{ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; mod bread_events; +mod listen; mod screenshot; // ---- Theming ---------------------------------------------------------------- @@ -454,6 +455,11 @@ fn run_ui(screenshot_req: Option) { // ---- Main ------------------------------------------------------------------- fn main() { + if std::env::args().nth(1).as_deref() == Some("listen") { + listen::run(); + return; + } + use clap::Parser; let cli = screenshot::Cli::parse(); let screenshot_req = cli.screenshot_request();