tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ 2f426b017111ccd82343188daa813e598fbe8509 crates/apps/nouser-explorer/src/main.rs Rust · 417 lines · 16.0 KB History Who touched what raw

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