Skip to main content

thread_engine/
directory.rs

1//! The worlds directory — a "jump anywhere" list of places on the Thread.
2//!
3//! Fetches the live world list (`GET <base>/thread/worlds`) on a background
4//! thread and offers it as a panel; selecting an entry veilwalks there. It's the
5//! browser's address book + a guaranteed way home, so you can never get stranded.
6
7use std::sync::mpsc::{channel, Receiver, TryRecvError};
8
9use serde::Deserialize;
10
11/// One listed world.
12#[derive(Debug, Clone)]
13pub struct WorldEntry {
14    pub title: String,
15    pub locator: String,
16    pub description: String,
17}
18
19#[derive(Deserialize)]
20struct WorldsResponse {
21    #[serde(default)]
22    worlds: Vec<RawEntry>,
23}
24
25#[derive(Deserialize)]
26struct RawEntry {
27    #[serde(default)]
28    title: String,
29    #[serde(default)]
30    locator: String,
31    #[serde(default)]
32    description: String,
33}
34
35/// The worlds directory.
36pub struct Directory {
37    base: String,
38    open: bool,
39    entries: Option<Vec<WorldEntry>>,
40    error: Option<String>,
41    pending: Option<Receiver<Result<Vec<WorldEntry>, String>>>,
42    /// The ranked live search: query text this result set answers, its results
43    /// (content match + portal-graph centrality, server-side), and the fetch
44    /// in flight. Last-typed wins; a stale landing is dropped.
45    search_query: String,
46    search_results: Option<Vec<SearchHit>>,
47    search_pending: Option<(String, Receiver<Result<Vec<SearchHit>, String>>)>,
48}
49
50/// One ranked search result from the Waystone index (`/thread/search`).
51#[derive(Debug, Clone)]
52pub struct SearchHit {
53    pub title: String,
54    pub locator: String,
55    /// Content snippet matching the query (the "why" of the hit).
56    pub snippet: String,
57    /// How many veils lead here — the Thread's PageRank cousin.
58    pub veils_in: u32,
59}
60
61impl Directory {
62    pub fn from_env() -> Self {
63        let base = std::env::var("INFINITE_THREAD_BASE")
64            .unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
65        Self {
66            base,
67            open: false,
68            entries: None,
69            error: None,
70            pending: None,
71            search_query: String::new(),
72            search_results: None,
73            search_pending: None,
74        }
75    }
76
77    pub fn is_open(&self) -> bool {
78        self.open
79    }
80    pub fn close(&mut self) {
81        self.open = false;
82    }
83
84    /// Open/close the directory. Fetches the list the first time it's opened.
85    pub fn toggle(&mut self) {
86        self.open = !self.open;
87        self.ensure_loaded();
88    }
89
90    /// Start fetching the world list if we don't have it yet (without opening the
91    /// panel) — so portal previews have data from the first frame.
92    pub fn ensure_loaded(&mut self) {
93        if self.entries.is_some() || self.pending.is_some() {
94            return;
95        }
96        self.error = None;
97        let url = format!("{}/thread/worlds", self.base.trim_end_matches('/'));
98        let (tx, rx) = channel();
99        std::thread::spawn(move || {
100            let _ = tx.send(fetch_worlds(&url));
101        });
102        self.pending = Some(rx);
103    }
104
105    /// Look up a listed world by Locator (ignoring `#anchor` / `@when`).
106    pub fn lookup(&self, locator: &str) -> Option<&WorldEntry> {
107        let key = normalize_locator(locator);
108        self.entries
109            .as_ref()?
110            .iter()
111            .find(|e| normalize_locator(&e.locator) == key)
112    }
113
114    /// Ask the Waystone index for ranked places matching `query` — content
115    /// match + portal-graph in-degree, the live "Google of the Thread" call.
116    /// Call freely as the user types: identical queries are deduped, a newer
117    /// query replaces the in-flight one (last-typed wins), and an empty query
118    /// clears the results (the panel falls back to the plain list).
119    pub fn search(&mut self, query: &str) {
120        let q = query.trim().to_string();
121        if q == self.search_query {
122            return;
123        }
124        self.search_query = q.clone();
125        if q.is_empty() {
126            self.search_results = None;
127            self.search_pending = None;
128            return;
129        }
130        let url = format!(
131            "{}/thread/search?q={}",
132            self.base.trim_end_matches('/'),
133            urlencode(&q)
134        );
135        let (tx, rx) = channel();
136        std::thread::spawn(move || {
137            let _ = tx.send(fetch_search(&url));
138        });
139        self.search_pending = Some((q, rx));
140    }
141
142    /// The ranked results for the current query, once landed.
143    pub fn search_results(&self) -> Option<&[SearchHit]> {
144        self.search_results.as_deref()
145    }
146    /// Whether a ranked search is still in flight.
147    pub fn search_loading(&self) -> bool {
148        self.search_pending.is_some()
149    }
150
151    /// Collect a finished fetch, if any. Call once per frame.
152    pub fn poll(&mut self) {
153        // Land a finished ranked search (only if it answers the current query).
154        if let Some((q, rx)) = &self.search_pending {
155            match rx.try_recv() {
156                Ok(Ok(hits)) => {
157                    if *q == self.search_query {
158                        self.search_results = Some(hits);
159                    }
160                    self.search_pending = None;
161                }
162                Ok(Err(_)) | Err(TryRecvError::Disconnected) => {
163                    // Ranked search failing is non-fatal — the panel's local
164                    // substring filter still works; just stop spinning.
165                    self.search_pending = None;
166                }
167                Err(TryRecvError::Empty) => {}
168            }
169        }
170        self.poll_worlds();
171    }
172
173    fn poll_worlds(&mut self) {
174        let Some(rx) = &self.pending else { return };
175        match rx.try_recv() {
176            Ok(Ok(list)) => {
177                self.entries = Some(list);
178                self.pending = None;
179            }
180            Ok(Err(e)) => {
181                self.error = Some(e);
182                self.pending = None;
183            }
184            Err(TryRecvError::Empty) => {}
185            Err(TryRecvError::Disconnected) => {
186                self.error = Some("fetch stopped".into());
187                self.pending = None;
188            }
189        }
190    }
191
192    pub fn entries(&self) -> Option<&[WorldEntry]> {
193        self.entries.as_deref()
194    }
195    pub fn is_loading(&self) -> bool {
196        self.pending.is_some()
197    }
198    pub fn error(&self) -> Option<&str> {
199        self.error.as_deref()
200    }
201}
202
203fn fetch_worlds(url: &str) -> Result<Vec<WorldEntry>, String> {
204    let rt = tokio::runtime::Builder::new_current_thread()
205        .enable_all()
206        .build()
207        .map_err(|e| e.to_string())?;
208    rt.block_on(async {
209        let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
210        if !resp.status().is_success() {
211            return Err(format!("HTTP {}", resp.status().as_u16()));
212        }
213        let text = resp.text().await.map_err(|e| e.to_string())?;
214        parse_worlds(&text)
215    })
216}
217
218fn fetch_search(url: &str) -> Result<Vec<SearchHit>, String> {
219    let rt = tokio::runtime::Builder::new_current_thread()
220        .enable_all()
221        .build()
222        .map_err(|e| e.to_string())?;
223    rt.block_on(async {
224        let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
225        if !resp.status().is_success() {
226            return Err(format!("HTTP {}", resp.status().as_u16()));
227        }
228        let text = resp.text().await.map_err(|e| e.to_string())?;
229        parse_search(&text)
230    })
231}
232
233/// Minimal percent-encoding for a query-string value.
234fn urlencode(s: &str) -> String {
235    let mut out = String::with_capacity(s.len());
236    for b in s.bytes() {
237        match b {
238            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
239                out.push(b as char)
240            }
241            b' ' => out.push('+'),
242            _ => out.push_str(&format!("%{b:02X}")),
243        }
244    }
245    out
246}
247
248#[derive(Deserialize)]
249struct SearchResponse {
250    #[serde(default)]
251    places: Vec<RawHit>,
252}
253
254#[derive(Deserialize)]
255struct RawHit {
256    #[serde(default)]
257    title: String,
258    #[serde(default)]
259    locator: String,
260    #[serde(default)]
261    snippet: String,
262    #[serde(default, rename = "veilsIn")]
263    veils_in: u32,
264}
265
266fn parse_search(json: &str) -> Result<Vec<SearchHit>, String> {
267    let parsed: SearchResponse = serde_json::from_str(json).map_err(|e| e.to_string())?;
268    Ok(parsed
269        .places
270        .into_iter()
271        .filter(|h| !h.locator.is_empty())
272        .map(|h| SearchHit {
273            title: h.title,
274            locator: h.locator,
275            snippet: h.snippet,
276            veils_in: h.veils_in,
277        })
278        .collect())
279}
280
281/// Strip a Locator to its base (`thread://host/path`) for comparison.
282fn normalize_locator(loc: &str) -> String {
283    loc.split(['#', '@'])
284        .next()
285        .unwrap_or(loc)
286        .trim_end_matches('/')
287        .to_string()
288}
289
290fn parse_worlds(json: &str) -> Result<Vec<WorldEntry>, String> {
291    let parsed: WorldsResponse = serde_json::from_str(json).map_err(|e| e.to_string())?;
292    Ok(parsed
293        .worlds
294        .into_iter()
295        .filter(|e| !e.locator.is_empty())
296        .map(|e| WorldEntry {
297            title: e.title,
298            locator: e.locator,
299            description: e.description,
300        })
301        .collect())
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn parses_ranked_search_hits() {
310        let json = r#"{"query":"weft","total":17,"count":2,"places":[
311            {"locator":"thread://weft.pixygon.io","title":"The Weavery","snippet":"packages","veilsIn":3,"score":1.5},
312            {"title":"No Locator","snippet":"dropped"},
313            {"locator":"thread://wiki.pixygon.io","title":"The Atrium","snippet":"clock","veilsIn":2}
314        ]}"#;
315        let hits = parse_search(json).unwrap();
316        assert_eq!(hits.len(), 2, "hits without a locator are dropped");
317        assert_eq!(hits[0].title, "The Weavery");
318        assert_eq!(hits[0].veils_in, 3);
319        assert_eq!(hits[1].snippet, "clock");
320    }
321
322    #[test]
323    fn urlencode_covers_query_text() {
324        assert_eq!(urlencode("the weavery"), "the+weavery");
325        assert_eq!(urlencode("a/b?c"), "a%2Fb%3Fc");
326        assert_eq!(urlencode("plain-text_1.0~x"), "plain-text_1.0~x");
327    }
328
329    #[test]
330    fn parses_the_worlds_list() {
331        let json = r#"{"worlds":[
332            {"id":"46000003","title":"The Nexus","locator":"thread://pixygon.io","description":"front door"},
333            {"title":"No Locator","description":"skipped"},
334            {"title":"The Forge","locator":"thread://pixiel.ai/forge","description":"workshop"}
335        ]}"#;
336        let list = parse_worlds(json).unwrap();
337        assert_eq!(list.len(), 2, "entries without a locator are dropped");
338        assert_eq!(list[0].title, "The Nexus");
339        assert_eq!(list[1].locator, "thread://pixiel.ai/forge");
340    }
341}