tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Definitions

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/apps/nouser-explorer/src/main.rs Rust · 386 lines · 14.9 KB History Who touched what raw

feat(yahweh-launcher): F3 — extracción del shell standard de explorers 37e40073e · Sergio ·

1//! `nouser-explorer` — panel GPUI que descubre al daemon `nouser`
2//! vía broker brahman y muestra sus Mónadas en vivo.
3//!
4//! Diseño: ventana standalone que cada N segundos consulta el query
5//! socket del daemon (`nouser_core::engine_socket::client::list_monads`).
6//! El path del socket NO está hardcoded — se descubre vía
7//! `brahman_sidecar::await_provider_blocking` para el flow
8//! `monad-list:json`. Si el daemon cae, el socket cacheado se invalida
9//! y la próxima iteración re-descubre.
10//!
11//! Sin integración con yahweh-shell — es su propio binario para que el
12//! ecosistema sea visible incluso sin la shell completa.
13//!
14//! Uso:
15//! ```sh
16//! cargo run -p nouser-explorer
17//! # con override del init socket (heredado de brahman-handshake):
18//! BRAHMAN_INIT_SOCKET=/tmp/init.sock cargo run -p nouser-explorer
19//! ```
20
21use std::path::PathBuf;
22use std::time::Duration;
23
24use brahman_sidecar::{await_provider_blocking, build_consumer_card, ConsumerError};
25use gpui::{
26    div, prelude::*, px, rgb, Context, IntoElement, Render, SharedString, Window,
27};
28use nouser_card::query::client as query_client;
29use nouser_card::query::{transport, ListMonadsResponse, FLOW_MONAD_LIST, FLOW_TYPE_NAME};
30use nouser_card::Lens;
31use yahweh_launcher::launch_app;
32use yahweh_theme::Theme;
33use yahweh_widget_app_header::app_header;
34use yahweh_widget_banner::{banner_themed, Banner};
35use yahweh_widget_card::card_themed;
36
37const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
38const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3);
39const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
40
41fn main() {
42    launch_app("Nouser — Mónadas", (900., 640.), Explorer::new);
43}
44
45/// Vista raíz: cachea el socket descubierto, el último snapshot y el
46/// último error. El socket cacheado se invalida ante cualquier fallo
47/// de query, forzando re-discovery en la próxima iteración.
48struct Explorer {
49    socket: Option<PathBuf>,
50    snapshot: Option<ListMonadsResponse>,
51    error: Option<SharedString>,
52    /// Última fuente del socket activo: "discovery" (vía broker) o
53    /// "cache" (reusando el de la iteración anterior). Sólo informativo.
54    socket_source: Option<&'static str>,
55}
56
57impl Explorer {
58    fn new(cx: &mut Context<Self>) -> Self {
59        // Loop de refresh: cada `REFRESH_INTERVAL`:
60        // 1. Si no tenemos socket cacheado → discovery vía broker.
61        // 2. Si tenemos → query directo. Fallo → invalida cache.
62        cx.spawn(async move |this, cx| {
63            let timer = cx.background_executor().clone();
64            loop {
65                let prior_socket = this
66                    .read_with(cx, |me, _| me.socket.clone())
67                    .ok()
68                    .flatten();
69
70                let result = tick(prior_socket);
71
72                let _ = this.update(cx, |me, cx| {
73                    match result {
74                        TickOutcome::Ok { socket, source, snapshot } => {
75                            me.socket = Some(socket);
76                            me.socket_source = Some(source);
77                            me.snapshot = Some(snapshot);
78                            me.error = None;
79                        }
80                        TickOutcome::DiscoveryFailed(msg) => {
81                            me.socket = None;
82                            me.socket_source = None;
83                            me.error = Some(SharedString::from(msg));
84                        }
85                        TickOutcome::QueryFailed(msg) => {
86                            // Invalidamos el socket cacheado: la
87                            // próxima iteración re-descubre.
88                            me.socket = None;
89                            me.socket_source = None;
90                            me.error = Some(SharedString::from(msg));
91                        }
92                    }
93                    cx.notify();
94                });
95                timer.timer(REFRESH_INTERVAL).await;
96            }
97        })
98        .detach();
99
100        Self {
101            socket: None,
102            snapshot: None,
103            error: None,
104            socket_source: None,
105        }
106    }
107}
108
109enum TickOutcome {
110    Ok {
111        socket: PathBuf,
112        source: &'static str,
113        snapshot: ListMonadsResponse,
114    },
115    DiscoveryFailed(String),
116    QueryFailed(String),
117}
118
119/// Resuelve el socket (cache → broker → default path) y consulta
120/// `ListMonads`. Pensado para correr en background: no toca GPUI,
121/// sólo I/O.
122///
123/// **Falla hacia la simplicidad**: si el broker brahman no está vivo
124/// (init caído / no instalado), intentamos directo el path canónico
125/// del daemon vía `transport::default_socket_path()`. El explorer
126/// sigue funcionando contra un daemon "huérfano" que no se publicó
127/// al broker — útil para correr la UI sin todo el stack.
128fn tick(prior_socket: Option<PathBuf>) -> TickOutcome {
129    let (socket, source) = match prior_socket {
130        Some(p) => (p, "cache"),
131        None => match resolve_socket() {
132            Ok(found) => found,
133            Err(e) => return TickOutcome::DiscoveryFailed(e),
134        },
135    };
136
137    match query_client::list_monads(&socket, QUERY_TIMEOUT) {
138        Ok(resp) => TickOutcome::Ok {
139            socket,
140            source,
141            snapshot: resp,
142        },
143        Err(e) => TickOutcome::QueryFailed(format!(
144            "query a {}: {e} — re-descubriendo en próxima iteración",
145            socket.display()
146        )),
147    }
148}
149
150/// Resuelve el socket del daemon en dos pasos:
151/// 1. **Broker**: consumer Card + `await_provider_blocking`. Path
152///    "consciente" (ecosistema brahman activo).
153/// 2. **Default path**: si el broker no responde, probamos
154///    `transport::default_socket_path()` directo. Path "soberano"
155///    (daemon corriendo solo, sin init).
156///
157/// Falla únicamente si ninguno responde.
158fn resolve_socket() -> Result<(PathBuf, &'static str), String> {
159    match discover_via_broker() {
160        Ok(p) => Ok((p, "broker")),
161        Err(broker_err) => {
162            let fallback = transport::default_socket_path();
163            if fallback.exists() {
164                Ok((fallback, "default-path"))
165            } else {
166                Err(format!(
167                    "broker: {broker_err}; fallback {} no existe",
168                    fallback.display()
169                ))
170            }
171        }
172    }
173}
174
175/// Discovery del daemon vía broker brahman. Construye un consumer
176/// Card con `flow.input = monad-list:json`, espera al primer
177/// `MatchEvent::Available`, devuelve el `producer_service_socket`.
178fn discover_via_broker() -> Result<PathBuf, ConsumerError> {
179    let card = build_consumer_card("nouser-explorer", FLOW_MONAD_LIST, FLOW_TYPE_NAME);
180    await_provider_blocking(card, DISCOVERY_TIMEOUT)
181}
182
183impl Render for Explorer {
184    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
185        // Chrome viene del Theme global; los acentos por kind
186        // (engine cyan, data purple) son señales semánticas del
187        // dominio nouser y se mantienen locales.
188        let theme = Theme::global(cx).clone();
189        let bg = theme.bg_app.clone();
190        let text = theme.fg_text;
191        let text_dim = theme.fg_muted;
192        let accent_engine = rgb(0x88c0d0);
193        let accent_data = rgb(0xb48ead);
194
195        let header_text = match (&self.snapshot, &self.socket, self.socket_source) {
196            (Some(s), Some(sock), Some(src)) => format!(
197                "Engine '{}'  ·  {} mónada(s)  ·  socket: {} ({}){}",
198                s.engine.label,
199                s.monads.len(),
200                sock.display(),
201                src,
202                s.engine
203                    .watching
204                    .as_deref()
205                    .map(|w| format!("  ·  watching: {}", w))
206                    .unwrap_or_default()
207            ),
208            _ => "Buscando daemon nouser vía brahman-broker…".to_string(),
209        };
210
211        // Header standard via widget compartido.
212        let header = app_header(cx, header_text);
213
214        let error_banner = self.error.as_ref().map(|e| {
215            banner_themed(cx, Banner::Error, e.clone())
216                .px(px(16.))
217                .py(px(8.))
218                .text_size(px(12.))
219        });
220
221        let cards: Vec<gpui::AnyElement> = match &self.snapshot {
222            None => vec![],
223            Some(snap) => {
224                let mut out = Vec::with_capacity(snap.monads.len() + 1);
225
226                // Engine card primero — el "ser" que owns las Mónadas.
227                out.push(
228                    card_themed(cx)
229                        .border_l_4()
230                        .border_color(accent_engine)
231                        .child(
232                            div()
233                                .flex()
234                                .flex_row()
235                                .gap(px(8.))
236                                .items_center()
237                                .child(
238                                    div()
239                                        .text_color(accent_engine)
240                                        .text_size(px(11.))
241                                        .child("[engine]"),
242                                )
243                                .child(
244                                    div()
245                                        .text_color(text)
246                                        .text_size(px(15.))
247                                        .child(snap.engine.label.clone()),
248                                ),
249                        )
250                        .child(
251                            div()
252                                .text_color(text_dim)
253                                .text_size(px(11.))
254                                .child(format!("id: {}", snap.engine.id)),
255                        )
256                        .when_some(snap.engine.watching.clone(), |d, w| {
257                            d.child(
258                                div()
259                                    .text_color(text_dim)
260                                    .text_size(px(11.))
261                                    .child(format!("watching: {w}")),
262                            )
263                        })
264                        .into_any_element(),
265                );
266
267                // Mónadas (kind=Data por construcción).
268                for m in &snap.monads {
269                    let lens = lens_label(m.dominant_lens);
270                    let keywords = m.keywords.join(", ");
271                    let path_hint_line = m
272                        .path_hint
273                        .as_deref()
274                        .filter(|p| !p.is_empty())
275                        .map(|p| format!("path: {p}"));
276                    let model_line = m
277                        .centroid_model
278                        .as_deref()
279                        .filter(|m| !m.is_empty())
280                        .map(|m| format!("model: {m}"));
281
282                    out.push(
283                        card_themed(cx)
284                            .border_l_4()
285                            .border_color(accent_data)
286                            .child(
287                                div()
288                                    .flex()
289                                    .flex_row()
290                                    .gap(px(8.))
291                                    .items_center()
292                                    .child(
293                                        div()
294                                            .text_color(accent_data)
295                                            .text_size(px(11.))
296                                            .child("[monad]"),
297                                    )
298                                    .child(
299                                        div()
300                                            .text_color(text)
301                                            .text_size(px(15.))
302                                            .child(m.label.clone()),
303                                    )
304                                    .child(
305                                        div()
306                                            .text_color(text_dim)
307                                            .text_size(px(11.))
308                                            .child(format!(
309                                                "{} files · ent {:.2} · {}",
310                                                m.cardinality, m.entropy, lens
311                                            )),
312                                    ),
313                            )
314                            .child(
315                                div()
316                                    .text_color(text_dim)
317                                    .text_size(px(11.))
318                                    .child(format!("id: {}", m.id)),
319                            )
320                            .when(!m.summary.is_empty(), |d| {
321                                d.child(
322                                    div()
323                                        .text_color(text)
324                                        .text_size(px(12.))
325                                        .child(m.summary.clone()),
326                                )
327                            })
328                            .when(!keywords.is_empty(), |d| {
329                                d.child(
330                                    div()
331                                        .text_color(text_dim)
332                                        .text_size(px(11.))
333                                        .child(format!("keywords: {keywords}")),
334                                )
335                            })
336                            .when_some(path_hint_line, |d, line| {
337                                d.child(
338                                    div()
339                                        .text_color(text_dim)
340                                        .text_size(px(11.))
341                                        .child(line),
342                                )
343                            })
344                            .when_some(model_line, |d, line| {
345                                d.child(
346                                    div()
347                                        .text_color(text_dim)
348                                        .text_size(px(11.))
349                                        .child(line),
350                                )
351                            })
352                            .into_any_element(),
353                    );
354                }
355                out
356            }
357        };
358
359        let body = div()
360            .flex()
361            .flex_col()
362            .p(px(16.))
363            .overflow_hidden()
364            .children(cards);
365
366        div()
367            .flex()
368            .flex_col()
369            .size_full()
370            .bg(bg)
371            .child(header)
372            .when_some(error_banner, |d, b| d.child(b))
373            .child(body)
374    }
375}
376
377fn lens_label(l: Lens) -> &'static str {
378    match l {
379        Lens::Grid => "grid",
380        Lens::Code => "code",
381        Lens::Gallery => "gallery",
382        Lens::Database => "database",
383        Lens::Markdown => "markdown",
384        Lens::Tree => "tree",
385    }
386}