⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd
crates/apps/text_viewer/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
chore: monorepo inicial con arje + minga + yahweh absorbidos
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}