Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7f59b5c
docs(plan): phase 2e — local-search implementation plan
intendednull Apr 21, 2026
55e9e00
ui(phase-2e): add local-search query parser + unit tests
intendednull Apr 21, 2026
7949a16
ui(phase-2e): tokenize message bodies preserving mentions + urls
intendednull Apr 21, 2026
d2028de
ui(phase-2e): add inverted index with insert/remove/evict
intendednull Apr 21, 2026
0fae8c2
ui(phase-2e): add scope-aware query executor + highlight stub
intendednull Apr 21, 2026
246add6
ui(phase-2e): cover highlight excerpts with dedicated tests
intendednull Apr 21, 2026
e9a1b01
ui(phase-2e): add search config + recents + build-status primitives
intendednull Apr 21, 2026
3919c29
ui(phase-2e): expose SearchIndexHandle on willow-client
intendednull Apr 21, 2026
f532c29
ui(phase-2e): add search UI signals + persist scope
intendednull Apr 21, 2026
7a9d7ca
ui(phase-2e): SearchInput + ScopeChip with keyboard + a11y
intendednull Apr 21, 2026
6204eeb
ui(phase-2e): render results list + row + highlight + recents + surface
intendednull Apr 21, 2026
7b11f30
ui(phase-2e): mount SearchSurface + palette bridge + / + ⌘F
intendednull Apr 21, 2026
8011575
ui(phase-2e): mobile top-bar search opens surface directly
intendednull Apr 21, 2026
ae76572
ui(phase-2e): sweep a11y contract + privacy guard + final tests
intendednull Apr 21, 2026
7edaa96
docs(plan): tick plan + record PR 187
intendednull Apr 21, 2026
d187a8a
ci: re-trigger CI
intendednull Apr 21, 2026
17c7f2e
merge: main into phase-2e/local-search
intendednull Apr 21, 2026
373ba49
ci(phase-2e): use sort_by_key + Reverse for clippy 1.95
intendednull Apr 21, 2026
32f3c24
Merge remote-tracking branch 'origin/main' into phase-2e/local-search
intendednull Apr 25, 2026
0c0872a
Merge remote-tracking branch 'origin/main' into phase-2e/local-search
intendednull Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ willow-common = { path = "../common" }
anyhow = { workspace = true }
blake3 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
Expand Down
5 changes: 5 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod ops;
pub mod persistence_actor;
pub mod presence;
pub mod queue;
pub mod search;
pub mod state;
pub mod state_actors;
pub mod storage;
Expand Down Expand Up @@ -73,6 +74,10 @@ pub use mentions::mentions_me;
pub use nickname::{MemNicknameStore, NicknameStore, NicknameStoreHandle, NICKNAME_CAP};
pub use ops::{pack_wire, unpack_wire, VoiceSignalPayload, WireMessage};
pub use queue::{ArrivedSummary, QueueSummary, RelayStatus};
pub use search::{
IndexableMessage, RecentQuery, SearchIndex, SearchIndexBuildStatus, SearchIndexConfig,
SearchIndexHandle, SearchQuery, SearchResult, SearchScope,
};
pub use trust::{
ComparePreview, InMemoryTrustStore, PeerTrust, TrustStore, TrustStoreHandle, UnverifiedReason,
};
Expand Down
76 changes: 76 additions & 0 deletions crates/client/src/search/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Per-device search settings and the recent-queries ring buffer.
//!
//! Per `docs/specs/2026-04-19-ui-design/local-search.md` §Privacy:
//! recents, per-grove toggles, horizon, and scope live **only on this
//! device** — they never ride the event stream. This module owns the
//! in-memory shape; `crate::storage` owns the per-target persistence
//! (native files / browser localStorage).

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Per-device search configuration.
///
/// Shipped defaults: `enabled=true`, `horizon_days=90`, `remember_recents=true`,
/// `per_grove_enabled` empty (every grove participates until explicitly
/// opted out).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SearchIndexConfig {
/// Master enable. `false` short-circuits the executor and hides
/// the results surface. Default `true`.
pub enabled: bool,
/// Days of history to retain in the index. Valid values per spec:
/// `30`, `90`, `365`, `u32::MAX` (= `all history`). Default `90`.
pub horizon_days: u32,
/// Whether to save recent queries locally. Default `true`.
pub remember_recents: bool,
/// Per-grove index opt-out. `false` = grove skipped at insert and
/// evicted on config save; missing / `true` = grove participates.
pub per_grove_enabled: HashMap<String, bool>,
}

impl Default for SearchIndexConfig {
fn default() -> Self {
Self {
enabled: true,
horizon_days: 90,
remember_recents: true,
per_grove_enabled: HashMap::new(),
}
}
}

/// Ring-buffer cap for recents. Per spec §Privacy the UI caps at 8.
pub const MAX_RECENTS: usize = 8;

/// One recent query. The raw text is preserved so the chip renders the
/// user's original casing; `timestamp_ms` drives the optional "latest-
/// first" ordering.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentQuery {
/// Raw query text (preserves user casing; not lowercased).
pub text: String,
/// Wall-clock of the push, in ms.
pub timestamp_ms: u64,
}

/// Push a new recent to the front; dedup by text; cap at [`MAX_RECENTS`].
pub fn push_recent(list: &mut Vec<RecentQuery>, r: RecentQuery) {
list.retain(|e| e.text != r.text);
list.insert(0, r);
if list.len() > MAX_RECENTS {
list.truncate(MAX_RECENTS);
}
}

/// Remove a single entry by its text. No-op if absent.
pub fn forget_recent(list: &mut Vec<RecentQuery>, text: &str) {
list.retain(|e| e.text != text);
}

/// Drop every recent. Paired with the spec's `clear all recents` UI
/// affordance.
pub fn clear_all_recents(list: &mut Vec<RecentQuery>) {
list.clear();
}
198 changes: 198 additions & 0 deletions crates/client/src/search/execute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! Scope-aware query executor.
//!
//! Per `docs/specs/2026-04-19-ui-design/local-search.md` §Scope ladder +
//! §Results presentation: executes a [`SearchQuery`] over a
//! [`SearchIndex`] under a [`SearchScope`], returning hits in
//! timestamp-desc order with matched byte-ranges ready for the
//! highlight renderer.

use chrono::NaiveDate;
use serde::{Deserialize, Serialize};

use super::index::{Posting, SearchIndex};
use super::query::{QueryFilters, SearchQuery};

/// Scope of a search invocation. Matches `local-search.md` §Scope ladder.
///
/// `Serialize` / `Deserialize` so the web UI can persist the user's
/// chosen scope across reloads via `localStorage`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id")]
pub enum SearchScope {
/// Only this letter's messages.
ThisLetter(String),
/// Only this channel's messages (channel id, not name — ids are stable).
ThisChannel(String),
/// Every peer + group letter on this device.
AllLetters,
/// Every grove channel plus every letter (widest).
AllGrovesAndLetters,
}

/// One hit emitted by [`execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResult {
pub message_id: String,
pub channel_id: String,
pub channel_name: String,
pub grove_id: Option<String>,
pub letter_id: Option<String>,
pub author_display_name: String,
pub author_handle: String,
pub timestamp_ms: u64,
pub body: String,
/// Byte ranges of each matched span inside `body`. Populated when
/// the highlight module lands (Task 5) — empty until then.
pub matched_ranges: Vec<(usize, usize)>,
}

/// Execute `query` over `index` under `scope`.
///
/// Returns hits in timestamp-desc order. Dedup is by `message_id`: a
/// message that matches via multiple tokens renders once.
pub fn execute(index: &SearchIndex, query: &SearchQuery, scope: &SearchScope) -> Vec<SearchResult> {
let candidates = candidate_postings(index, query);

let mut seen = std::collections::HashSet::new();
let mut out: Vec<SearchResult> = candidates
.into_iter()
.filter(|p| scope_admits(p, scope))
.filter(|p| filters_admit(p, &query.filters))
.filter(|p| body_admits(p, query))
.filter(|p| seen.insert(p.message_id.clone()))
.map(|p| SearchResult {
message_id: p.message_id.clone(),
channel_id: p.channel_id.clone(),
channel_name: p.channel_name.clone(),
grove_id: p.grove_id.clone(),
letter_id: p.letter_id.clone(),
author_display_name: p.author_display_name.clone(),
author_handle: p.author_handle.clone(),
timestamp_ms: p.timestamp_ms,
body: p.body.clone(),
matched_ranges: super::highlight::match_ranges(&p.body, query),
})
.collect();

out.sort_by_key(|p| std::cmp::Reverse(p.timestamp_ms));
out
}

/// Candidate set: posting-lists for every token + first-word of each
/// phrase. Falls back to every posting when the query has no tokens
/// (pure operator-only queries like `has:link`).
fn candidate_postings(index: &SearchIndex, query: &SearchQuery) -> Vec<Posting> {
let mut lookup_tokens: Vec<String> = query.tokens.clone();
for ph in &query.phrases {
if let Some(first) = ph.split_whitespace().next() {
lookup_tokens.push(first.to_string());
}
}

let mut candidates: Vec<Posting> = Vec::new();
for t in &lookup_tokens {
if let Some(slice) = index.postings_for(t) {
candidates.extend(slice.iter().cloned());
}
}

if candidates.is_empty() {
candidates = index.all_postings().into_iter().cloned().collect();
}

candidates
}

fn scope_admits(p: &Posting, scope: &SearchScope) -> bool {
match scope {
SearchScope::ThisLetter(id) => p.letter_id.as_deref() == Some(id.as_str()),
SearchScope::ThisChannel(id) => p.channel_id == *id,
SearchScope::AllLetters => p.letter_id.is_some(),
SearchScope::AllGrovesAndLetters => true,
}
}

fn filters_admit(p: &Posting, f: &QueryFilters) -> bool {
if let Some(h) = &f.from_author {
let lc = h.to_lowercase();
if p.author_handle.to_lowercase() != lc && p.author_display_name.to_lowercase() != lc {
return false;
}
}
if let Some(c) = &f.in_channel {
if p.channel_name.to_lowercase() != c.to_lowercase() {
return false;
}
}
if let Some(d) = f.since {
if p.timestamp_ms < local_midnight_ms(d) {
return false;
}
}
if let Some(d) = f.before {
if p.timestamp_ms >= local_midnight_ms(d) {
return false;
}
}
if f.has_image && !p.has_image {
return false;
}
if f.has_file && !p.has_file {
return false;
}
if f.has_link && !p.has_link {
return false;
}
true
}

/// Convert a [`NaiveDate`] into a millisecond-epoch cutoff using local
/// time. Per spec §Query language: `since:` / `before:` are "local
/// timezone" boundaries.
///
/// Uses `chrono::Local` which compiles on both native and wasm32.
/// On wasm the browser's timezone drives the offset.
fn local_midnight_ms(d: NaiveDate) -> u64 {
use chrono::TimeZone;
let naive = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
let ts = chrono::Local
.from_local_datetime(&naive)
.single()
.map(|dt| dt.timestamp_millis())
.unwrap_or_else(|| {
// Fallback for DST-ambiguous local times: use UTC
// midnight — deliberately coarse. Spec says "local
// timezone" so this branch is rare and the UI will flag
// a warning via the parser in a future pass if needed.
chrono::Utc.from_utc_datetime(&naive).timestamp_millis()
});
ts.max(0) as u64
}

/// Every token in `query.tokens` must appear; every phrase must appear
/// as a contiguous substring. Case-insensitive throughout.
///
/// Tokens match across body + author + channel so `hello from:@mira`
/// finds messages where "mira" is the author even if the body doesn't
/// say "mira" — per spec §Query language.
fn body_admits(p: &Posting, q: &SearchQuery) -> bool {
let body_lc = p.body.to_lowercase();
let display_lc = p.author_display_name.to_lowercase();
let handle_lc = p.author_handle.to_lowercase();
let channel_lc = p.channel_name.to_lowercase();
for t in &q.tokens {
if !body_lc.contains(t)
&& !display_lc.contains(t)
&& !handle_lc.contains(t)
&& !channel_lc.contains(t)
{
return false;
}
}
for ph in &q.phrases {
if !body_lc.contains(ph) {
return false;
}
}
true
}
Loading
Loading