tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd crates/apps/text_viewer/src/lib.rs Rust · 285 siq'ikuna · 9.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: monorepo inicial con arje + minga + yahweh absorbidos 53dbdf0f1 · Sergio ·

1//! `yahweh_text_viewer` — visor de texto plano.
2//!
3//! Suscribe al `AppBus` y, en cada `EntitySelected` / `EntityOpened`,
4//! decide si el `provider` corresponde a uno que sabe leer (por ahora
5//! `local_fs` y `sqlite_db`); si sí, dispara `cx.spawn` con el provider
6//! correspondiente para traer el contenido. Mientras carga muestra
7//! "(cargando…)"; al terminar lo pinta como texto con saltos de línea
8//! preservados.
9//!
10//! Si el contenido no es válido UTF-8 (binario), muestra los primeros
11//! N bytes en hex — útil para preview no ciego sin pretender ser un
12//! editor de binarios.
13
14use std::sync::Arc;
15
16use gpui::{
17    Context, Entity, IntoElement, Render, SharedString, Window, div, prelude::*, px,
18};
19
20use yahweh_bus::{AppBus, AppEvent};
21use yahweh_core::DataProvider;
22use yahweh_provider_fs::{FileDataProvider, PROVIDER_ID as FS_PROVIDER_ID};
23use yahweh_provider_sqlite::{PROVIDER_ID as SQL_PROVIDER_ID, SqliteDataProvider};
24use yahweh_theme::Theme;
25
26const PREVIEW_HEX_BYTES: usize = 256;
27const MAX_TEXT_BYTES: usize = 256 * 1024;
28
29pub struct TextViewer {
30    /// Última entidad mostrada. `None` ⇒ pantalla en estado "vacío".
31    current: Option<CurrentEntity>,
32    /// Contenido renderizado. Si está cargando se muestra el estado en
33    /// `current`.
34    content: Content,
35    /// Generación monotónica — al cambiar `current` la incrementamos para
36    /// descartar resultados de loads previos que vuelvan tarde.
37    generation: u64,
38}
39
40#[derive(Clone, Debug)]
41struct CurrentEntity {
42    provider: String,
43    provider_path: Option<String>,
44    id: String,
45    loading: bool,
46}
47
48#[derive(Clone)]
49enum Content {
50    Empty,
51    Loading,
52    Text(SharedString),
53    HexPreview(SharedString),
54    Error(SharedString),
55    Unsupported(SharedString),
56}
57
58impl TextViewer {
59    pub fn new(bus: Entity<AppBus>, cx: &mut Context<Self>) -> Self {
60        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
61
62        cx.subscribe(&bus, |this: &mut TextViewer, _, ev, cx| {
63            this.on_app_event(ev, cx);
64        })
65        .detach();
66
67        Self {
68            current: None,
69            content: Content::Empty,
70            generation: 0,
71        }
72    }
73
74    fn on_app_event(&mut self, event: &AppEvent, cx: &mut Context<Self>) {
75        let (provider, provider_path, id) = match event {
76            AppEvent::EntitySelected { provider, provider_path, id }
77            | AppEvent::EntityOpened { provider, provider_path, id } => {
78                (provider.clone(), provider_path.clone(), id.clone())
79            }
80        };
81
82        // Comparar con el actual para evitar reload de lo mismo.
83        if let Some(cur) = &self.current {
84            if cur.provider == provider && cur.id == id && cur.provider_path == provider_path {
85                return;
86            }
87        }
88
89        self.generation = self.generation.wrapping_add(1);
90        let gen = self.generation;
91        self.current = Some(CurrentEntity {
92            provider: provider.clone(),
93            provider_path: provider_path.clone(),
94            id: id.clone(),
95            loading: true,
96        });
97        self.content = Content::Loading;
98        cx.notify();
99
100        // Dispatch por provider.
101        if provider == FS_PROVIDER_ID {
102            self.spawn_load_fs(id, gen, cx);
103        } else if provider == SQL_PROVIDER_ID {
104            self.spawn_load_sqlite(provider_path, id, gen, cx);
105        } else {
106            self.content = Content::Unsupported(
107                format!("provider '{}' no soportado por TextViewer", provider).into(),
108            );
109            if let Some(cur) = &mut self.current {
110                cur.loading = false;
111            }
112            cx.notify();
113        }
114    }
115
116    fn spawn_load_fs(&self, path: String, gen: u64, cx: &mut Context<Self>) {
117        let provider = Arc::new(FileDataProvider);
118        cx.spawn(async move |this, cx| {
119            let result = provider.get_data(&path).await;
120            let _ = this.update(cx, |this, cx| this.on_loaded(gen, result, cx));
121        })
122        .detach();
123    }
124
125    fn spawn_load_sqlite(
126        &self,
127        provider_path: Option<String>,
128        id: String,
129        gen: u64,
130        cx: &mut Context<Self>,
131    ) {
132        let db_path = provider_path.unwrap_or_else(|| "yahweh.db".to_string());
133        cx.spawn(async move |this, cx| {
134            // El SqliteDataProvider abre la DB en su constructor — si
135            // falla, reportamos error y salimos.
136            let provider = match SqliteDataProvider::new(&db_path) {
137                Ok(p) => p,
138                Err(e) => {
139                    let _ = this.update(cx, |this, cx| {
140                        this.on_loaded(
141                            gen,
142                            Err(format!("abriendo {}: {}", db_path, e)),
143                            cx,
144                        )
145                    });
146                    return;
147                }
148            };
149            let result = provider.get_data(&id).await;
150            let _ = this.update(cx, |this, cx| this.on_loaded(gen, result, cx));
151        })
152        .detach();
153    }
154
155    fn on_loaded(
156        &mut self,
157        gen: u64,
158        result: Result<Vec<u8>, String>,
159        cx: &mut Context<Self>,
160    ) {
161        // Si el usuario cambió de selección antes de que volviera el load,
162        // descartamos este resultado.
163        if gen != self.generation {
164            return;
165        }
166
167        if let Some(cur) = &mut self.current {
168            cur.loading = false;
169        }
170
171        self.content = match result {
172            Ok(bytes) => bytes_to_content(&bytes),
173            Err(e) => Content::Error(format!("error: {}", e).into()),
174        };
175        cx.notify();
176    }
177}
178
179fn bytes_to_content(bytes: &[u8]) -> Content {
180    if bytes.is_empty() {
181        return Content::Text("(vacío)".into());
182    }
183    let truncated = bytes.len() > MAX_TEXT_BYTES;
184    let slice = if truncated { &bytes[..MAX_TEXT_BYTES] } else { bytes };
185    match std::str::from_utf8(slice) {
186        Ok(s) => {
187            let mut out = s.to_string();
188            if truncated {
189                out.push_str("\n…(truncado)…");
190            }
191            Content::Text(out.into())
192        }
193        Err(_) => {
194            // No es UTF-8: mostramos hex preview de los primeros bytes.
195            let n = bytes.len().min(PREVIEW_HEX_BYTES);
196            let mut hex = String::with_capacity(n * 3);
197            for (i, b) in bytes[..n].iter().enumerate() {
198                if i > 0 && i % 16 == 0 {
199                    hex.push('\n');
200                }
201                hex.push_str(&format!("{:02x} ", b));
202            }
203            if bytes.len() > n {
204                hex.push_str(&format!("\n…({} bytes más)", bytes.len() - n));
205            }
206            Content::HexPreview(hex.into())
207        }
208    }
209}
210
211impl Render for TextViewer {
212    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
213        let theme = Theme::global(cx).clone();
214
215        let header_text = match &self.current {
216            None => "(ningún archivo seleccionado)".to_string(),
217            Some(cur) => {
218                let suffix = if cur.loading { " ⏳" } else { "" };
219                format!("[{}] {}{}", cur.provider, cur.id, suffix)
220            }
221        };
222
223        let body: gpui::AnyElement = match &self.content {
224            Content::Empty => div()
225                .text_color(theme.fg_muted)
226                .text_size(px(11.0))
227                .child("seleccioná un archivo en el FileExplorer o una entry en el DatabaseExplorer.")
228                .into_any_element(),
229            Content::Loading => div()
230                .text_color(theme.fg_muted)
231                .text_size(px(11.0))
232                .child("(cargando…)")
233                .into_any_element(),
234            Content::Text(s) => div()
235                .text_color(theme.fg_text)
236                .text_size(px(12.0))
237                .font_family("monospace")
238                .child(s.clone())
239                .into_any_element(),
240            Content::HexPreview(s) => div()
241                .text_color(theme.fg_muted)
242                .text_size(px(11.0))
243                .font_family("monospace")
244                .child(s.clone())
245                .into_any_element(),
246            Content::Error(s) => div()
247                .text_color(theme.accent_strong)
248                .text_size(px(11.0))
249                .child(s.clone())
250                .into_any_element(),
251            Content::Unsupported(s) => div()
252                .text_color(theme.fg_muted)
253                .text_size(px(11.0))
254                .child(s.clone())
255                .into_any_element(),
256        };
257
258        div()
259            .size_full()
260            .bg(theme.bg_panel.clone())
261            .flex()
262            .flex_col()
263            .child(
264                div()
265                    .h(px(28.0))
266                    .px(px(10.0))
267                    .border_b_1()
268                    .border_color(theme.border)
269                    .flex()
270                    .items_center()
271                    .text_size(px(11.0))
272                    .text_color(theme.fg_muted)
273                    .child(SharedString::from(header_text)),
274            )
275            .child(
276                div()
277                    .id("text-viewer-body")
278                    .flex_grow()
279                    .min_h(px(0.0))
280                    .overflow_scroll()
281                    .p(px(12.0))
282                    .child(body),
283            )
284    }
285}