From 4bfcc694bd1d075c3171f7b421a0c909d437606a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:39:11 +0800 Subject: [PATCH 1/2] Adopt gtk_popup overlay helpers and singleton::toggle_or_kill Replace the TOCTOU pid-file toggle with bread_utils::singleton and the hand-rolled layer-shell overlay/Up-Down/click-outside with gtk_popup, matching breadclip. Screenshot runs still skip the lock. --- Cargo.lock | 2 + breadsearch/Cargo.toml | 2 +- breadsearch/src/main.rs | 157 +++++++++++----------------------------- 3 files changed, 47 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60a3e79..77a2b16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,8 @@ source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517 dependencies = [ "bread-shared", "dirs", + "gtk4", + "gtk4-layer-shell", "serde", "serde_json", ] diff --git a/breadsearch/Cargo.toml b/breadsearch/Cargo.toml index 4b200b7..2c11e28 100644 --- a/breadsearch/Cargo.toml +++ b/breadsearch/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" breadsearch-shared = { path = "../breadsearch-shared" } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } # Bread event fabric client — emit bread.search.* (fail-silent if breadd is down). -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client", "gtk"] } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } gtk4 = { version = "0.11", features = ["v4_12"] } diff --git a/breadsearch/src/main.rs b/breadsearch/src/main.rs index d4d4786..53302ec 100644 --- a/breadsearch/src/main.rs +++ b/breadsearch/src/main.rs @@ -1,22 +1,12 @@ use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette}; use breadsearch_shared::{Hit, Request, Response}; -use std::{ - cell::RefCell, - env, fs, - path::PathBuf, - process::Command, - rc::Rc, - sync::mpsc, -}; +use std::{cell::RefCell, process::Command, rc::Rc, sync::mpsc}; use gtk4::{ - glib, - pango::EllipsizeMode, - prelude::*, - Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Image, Label, - ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, + glib, pango::EllipsizeMode, prelude::*, Application, Box as GBox, CssProvider, + EventControllerKey, Image, Label, ListBox, Orientation, PolicyType, ScrolledWindow, + SearchEntry, SelectionMode, }; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; mod bread_events; mod listen; @@ -43,47 +33,14 @@ fn build_css(p: &Palette) -> String { .hit-snippet {{ opacity: 0.75; font-size: 11px; font-style: italic; }}\ .hit-score {{ opacity: 0.5; font-size: 11px; }}\ image {{ margin-right: 8px; }}", - bg_panel = bg_panel, - surface = p.color0, - accent = p.color4, - on_bg = ink_on(&p.background), + bg_panel = bg_panel, + surface = p.color0, + accent = p.color4, + on_bg = ink_on(&p.background), on_surface = ink_on(&p.color0), ) } -// ---- PID file toggle -------------------------------------------------------- - -fn pid_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadsearch.pid") -} - -fn is_breadsearch_pid(pid: u32) -> bool { - fs::read_to_string(format!("/proc/{}/comm", pid)) - .map(|s| s.trim() == "breadsearch") - .unwrap_or(false) -} - -fn toggle_or_continue() -> bool { - let pf = pid_file(); - if let Ok(content) = fs::read_to_string(&pf) { - if let Ok(pid) = content.trim().parse::() { - if is_breadsearch_pid(pid) { - let _ = Command::new("kill").arg(pid.to_string()).status(); - return false; - } - } - } - let _ = fs::write(&pf, std::process::id().to_string()); - true -} - -fn cleanup_pid() { - let _ = fs::remove_file(pid_file()); -} - // ---- Row builder ------------------------------------------------------------ fn make_hit_row(hit: &Hit) -> gtk4::ListBoxRow { @@ -238,21 +195,13 @@ fn run_ui(screenshot_req: Option) { bread_theme::gtk::apply_user_css(&user_css_path, &user_cell); } - let window = ApplicationWindow::builder().application(app).build(); - window.init_layer_shell(); - window.set_namespace(Some("breadsearch")); - window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::Exclusive); - for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { - window.set_anchor(edge, true); - } - window.set_exclusive_zone(0); + // Full-screen transparent overlay; panel widget is positioned inside it. + let window = bread_utils::gtk_popup::new_overlay_window(app, "breadsearch"); bread_theme::gtk::bind_window_auto(&window); let close_all: Rc = Rc::new({ let w = window.clone(); move || { - cleanup_pid(); w.close(); } }); @@ -308,7 +257,10 @@ fn run_ui(screenshot_req: Option) { let (tx, rx) = mpsc::sync_channel::>(1); std::thread::spawn(move || { - let req = Request::Query { query: q, limit: 10 }; + let req = Request::Query { + query: q, + limit: 10, + }; let _ = tx.send(breadsearch_shared::send_request(&req)); }); @@ -368,36 +320,11 @@ fn run_ui(screenshot_req: Option) { glib::Propagation::Stop } Key::Down => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); - let mut i = cur + 1; - loop { - match list_k.row_at_index(i) { - Some(r) if r.is_selectable() => { - list_k.select_row(Some(&r)); - break; - } - Some(_) => i += 1, - None => break, - } - } + bread_utils::gtk_popup::select_next_visible(&list_k); glib::Propagation::Stop } Key::Up => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); - let mut i = cur - 1; - loop { - if i < 0 { - break; - } - match list_k.row_at_index(i) { - Some(r) if r.is_selectable() => { - list_k.select_row(Some(&r)); - break; - } - Some(_) => i -= 1, - None => break, - } - } + bread_utils::gtk_popup::select_prev_visible(&list_k); glib::Propagation::Stop } _ => glib::Propagation::Proceed, @@ -415,24 +342,10 @@ fn run_ui(screenshot_req: Option) { }); // Click outside launcher panel → close - let close_outside = Rc::clone(&close_all); - let vbox_ref = vbox.clone(); - let win_ref = window.clone(); - let outside_click = gtk4::GestureClick::new(); - outside_click.connect_pressed(move |_, _, x, y| { - if let Some(b) = vbox_ref.compute_bounds(&win_ref) { - if x < b.x() as f64 - || x > (b.x() + b.width()) as f64 - || y < b.y() as f64 - || y > (b.y() + b.height()) as f64 - { - close_outside(); - } - } - }); - window.add_controller(outside_click); - - window.connect_destroy(|_| cleanup_pid()); + { + let close_outside = Rc::clone(&close_all); + bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside()); + } if let Some(req) = screenshot_req.clone() { screenshot::dispatch(&window, req); @@ -465,11 +378,29 @@ fn main() { let cli = screenshot::Cli::parse(); let screenshot_req = cli.screenshot_request(); - // The PID-file toggle kills whatever's holding the file — a real, - // already-running breadsearch instance included. A screenshot run must - // never touch it: it's a separate, disposable instance by design. - if screenshot_req.is_none() && !toggle_or_continue() { - return; - } + // `toggle_or_kill` kills whatever's holding the single-instance lock — + // a real, already-running breadsearch included. A screenshot run must + // never touch it: it's a separate, disposable instance by design (same + // reasoning as breadbar's `allow_multiple_instances`), not a toggle of + // the operator's real search panel. + // + // Kept alive for the rest of `main` — dropping it releases the + // single-instance lock and removes the pid file, which happens + // naturally once `run_ui` returns (after the window closes). + let _singleton_guard = if screenshot_req.is_some() { + None + } else { + match bread_utils::singleton::toggle_or_kill("breadsearch") { + Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), + Ok(bread_utils::singleton::Toggle::KilledExisting) => return, + Err(e) => { + eprintln!( + "breadsearch: single-instance lock unavailable ({e}); continuing without it" + ); + None + } + } + }; + run_ui(screenshot_req); } From 993dc4a52546d28c5b1b1ee73298eb9a198f677c Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 6 Aug 2026 08:52:18 +0800 Subject: [PATCH 2/2] Fix cargo clippy warnings across the workspace - Derive Default for Config instead of manually mirroring each sub-config's Default impl - Use std::io::Error::other instead of Error::new(ErrorKind::Other, ..) - Drop the unused mut on the store lock in full_reindex - Remove Store::dim, a field that was set once and never read - Use char_indices().enumerate() instead of a hand-rolled loop counter in split_by_chars, and is_multiple_of() for the chunk boundary check - Use strip_prefix instead of manual slicing in expand_home (cherry picked from commit b740af38e17d04bd83db93bbb0585ed5744a436e) --- breadmill/src/chunk.rs | 6 ++---- breadmill/src/indexer.rs | 6 +++--- breadmill/src/store.rs | 3 +-- breadsearch-shared/src/lib.rs | 15 ++------------- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/breadmill/src/chunk.rs b/breadmill/src/chunk.rs index dcddca8..7468f89 100644 --- a/breadmill/src/chunk.rs +++ b/breadmill/src/chunk.rs @@ -81,10 +81,9 @@ fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec { let text = &chunk.text; let mut result = Vec::new(); let mut seg_start = 0usize; - let mut count = 0usize; - for (byte_idx, _) in text.char_indices() { - if count > 0 && count % max_chars == 0 { + for (count, (byte_idx, _)) in text.char_indices().enumerate() { + if count > 0 && count.is_multiple_of(max_chars) { result.push(Chunk { text: text[seg_start..byte_idx].to_string(), start: chunk.start + seg_start, @@ -92,7 +91,6 @@ fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec { }); seg_start = byte_idx; } - count += 1; } if seg_start < text.len() { result.push(Chunk { diff --git a/breadmill/src/indexer.rs b/breadmill/src/indexer.rs index 539b58c..67c7694 100644 --- a/breadmill/src/indexer.rs +++ b/breadmill/src/indexer.rs @@ -64,7 +64,7 @@ impl Indexer { pub fn full_reindex(&self) { eprintln!("breadmill: full reindex triggered"); { - let mut store = self.state.store.lock_recover(); + let store = self.state.store.lock_recover(); // Clear all state let _ = store.conn.execute_batch("DELETE FROM chunks; DELETE FROM files;"); let _ = store.index.reserve(4096); @@ -416,9 +416,9 @@ fn sha256_str(bytes: &[u8]) -> String { } pub fn expand_home(path: &str) -> PathBuf { - if path.starts_with("~/") { + if let Some(rest) = path.strip_prefix("~/") { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); - PathBuf::from(home).join(&path[2..]) + PathBuf::from(home).join(rest) } else { PathBuf::from(path) } diff --git a/breadmill/src/store.rs b/breadmill/src/store.rs index ed1392a..106e4c5 100644 --- a/breadmill/src/store.rs +++ b/breadmill/src/store.rs @@ -6,7 +6,6 @@ use usearch::{Index, IndexOptions, MetricKind, ScalarKind, new_index}; pub struct Store { pub conn: Connection, pub index: Index, - pub dim: usize, } // usearch::Index wraps a raw C++ pointer; access is serialized by the Mutex. @@ -87,7 +86,7 @@ impl Store { index.reserve(4096).map_err(|e| e.to_string())?; } - Ok(Self { conn, index, dim }) + Ok(Self { conn, index }) } // ---- file state --------------------------------------------------------- diff --git a/breadsearch-shared/src/lib.rs b/breadsearch-shared/src/lib.rs index fdea6f5..5ed69c4 100644 --- a/breadsearch-shared/src/lib.rs +++ b/breadsearch-shared/src/lib.rs @@ -43,7 +43,7 @@ pub fn socket_path() -> PathBuf { // ---- Config ----------------------------------------------------------------- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub index: IndexConfig, @@ -157,16 +157,6 @@ impl Default for ModelConfig { } } -impl Default for Config { - fn default() -> Self { - Self { - index: IndexConfig::default(), - search: SearchConfig::default(), - model: ModelConfig::default(), - power: PowerConfig::default(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PowerConfig { @@ -255,8 +245,7 @@ pub struct StatusInfo { pub fn send_request(req: &Request) -> std::io::Result { let mut stream = UnixStream::connect(socket_path())?; - let mut line = serde_json::to_string(req) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + let mut line = serde_json::to_string(req).map_err(std::io::Error::other)?; line.push('\n'); stream.write_all(line.as_bytes())?; stream.flush()?;