Files
brahman/crates/apps/nahual-database-explorer/src/lib.rs
T
sergio 550c98f275 refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 14:48:34 +00:00

285 lines
9.9 KiB
Rust

//! `nahual_database_explorer` — explorer de SQLite.
//!
//! Mismo patrón que `nahual_file_explorer` pero con `SqliteProvider`. La
//! UX es idéntica (TreeView con lazy load por chevron); cambia solo el
//! origen de los datos: filas de una tabla `items(id, parent_id, name,
//! display_type, content)` en lugar del filesystem.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use gpui::{
Context, Entity, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*,
px,
};
use nahual_core::{DataProvider, DisplayType, EntityNode};
use nahual_provider_sqlite::SqliteDataProvider;
use nahual_theme::Theme;
use nahual_widget_tree::{RowId, RowKind, TreeEvent, TreeRow, TreeView};
#[derive(Clone, Debug)]
#[allow(dead_code)] // Consumido por el AppBus en Fase 4+.
pub enum DatabaseExplorerEvent {
EntitySelected { id: String },
EntityOpened { id: String },
}
pub struct DatabaseExplorer {
tree_view: Entity<TreeView>,
provider: Arc<SqliteDataProvider>,
db_path: String,
expanded: HashSet<String>,
children: HashMap<String, Vec<EntityNode>>,
pending: HashSet<String>,
/// Mensaje de error si la DB no abrió. Se muestra en el header.
open_error: Option<String>,
}
const ROOT_KEY: &str = "__db_root__";
impl EventEmitter<DatabaseExplorerEvent> for DatabaseExplorer {}
impl DatabaseExplorer {
/// `db_path` es la ruta al .sqlite. Si no existe se crea con la tabla
/// `items` y un seed mínimo (ver `SqliteDataProvider::new`).
pub fn new(db_path: String, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
let tree_view = cx.new(|cx| TreeView::new("db-explorer-tree", cx));
cx.subscribe(&tree_view, |this: &mut DatabaseExplorer, _, ev, cx| {
this.on_tree_event(ev, cx);
})
.detach();
let (provider, open_error) = match SqliteDataProvider::new(&db_path) {
Ok(p) => (Some(Arc::new(p)), None),
Err(e) => (None, Some(e)),
};
let mut expanded = HashSet::new();
expanded.insert(ROOT_KEY.to_string());
let mut me = Self {
tree_view,
// Usamos un dummy provider si la DB no abrió. La UI mostrará el
// error en el header; cualquier load_children retornará vacío.
provider: provider.unwrap_or_else(|| {
Arc::new(SqliteDataProvider::new(":memory:").expect("memory db"))
}),
db_path,
expanded,
children: HashMap::new(),
pending: HashSet::new(),
open_error,
};
// Cargar el root (parent_id NULL en SQLite, mapped to None acá).
me.load_children(ROOT_KEY.to_string(), cx);
me
}
pub fn db_path(&self) -> &str {
&self.db_path
}
fn load_children(&mut self, parent_key: String, cx: &mut Context<Self>) {
if self.pending.contains(&parent_key) || self.children.contains_key(&parent_key) {
return;
}
self.pending.insert(parent_key.clone());
let provider = self.provider.clone();
let parent_for_task = parent_key.clone();
cx.spawn(async move |this, cx| {
// ROOT_KEY → None; cualquier otro → Some(actual id).
let arg: Option<String> = if parent_for_task == ROOT_KEY {
None
} else {
Some(parent_for_task.clone())
};
let result = provider.list_children(arg.as_deref()).await;
let _ = this.update(cx, |this, cx| {
this.on_children_loaded(parent_for_task, result, cx);
});
})
.detach();
}
fn on_children_loaded(
&mut self,
parent_key: String,
result: Result<Vec<EntityNode>, String>,
cx: &mut Context<Self>,
) {
self.pending.remove(&parent_key);
let mut entries = result.unwrap_or_default();
sort_entries(&mut entries);
self.children.insert(parent_key, entries);
self.push_rows(cx);
}
fn push_rows(&self, cx: &mut Context<Self>) {
let mut rows = Vec::new();
// Una row "raíz virtual" para que el árbol tenga un anchor visible.
rows.push(TreeRow {
id: RowId::new(ROOT_KEY),
label: format!("(db) {}", self.db_path),
depth: 0,
kind: RowKind::Branch,
expanded: self.expanded.contains(ROOT_KEY),
icon: Some("🗄️".to_string()),
});
if self.expanded.contains(ROOT_KEY) {
self.append_children(ROOT_KEY, 1, &mut rows);
}
self.tree_view
.update(&mut *cx, |tree, cx| tree.set_rows(rows, cx));
}
fn append_children(&self, parent: &str, depth: u32, out: &mut Vec<TreeRow>) {
let Some(children) = self.children.get(parent) else { return };
for entry in children {
let kind = match entry.display_type {
DisplayType::Folder => RowKind::Branch,
_ => RowKind::Leaf,
};
let icon = match entry.display_type {
DisplayType::Folder => "📂",
DisplayType::File => "📄",
DisplayType::Stream => "📡",
};
let is_expanded = self.expanded.contains(&entry.id);
out.push(TreeRow {
id: RowId::new(entry.id.clone()),
label: entry.name.clone(),
depth,
kind,
expanded: is_expanded,
icon: Some(icon.to_string()),
});
if is_expanded {
self.append_children(&entry.id, depth + 1, out);
}
}
}
fn on_tree_event(&mut self, event: &TreeEvent, cx: &mut Context<Self>) {
match event {
TreeEvent::ChevronToggled(id) => {
let key = id.as_str().to_string();
if !self.expanded.remove(&key) {
self.expanded.insert(key.clone());
self.load_children(key, cx);
}
self.push_rows(cx);
}
TreeEvent::RowClicked(id) => {
let key = id.as_str();
if key == ROOT_KEY {
return;
}
if let Some(entry) = self.find_entry(key) {
if !matches!(entry.display_type, DisplayType::Folder) {
cx.emit(DatabaseExplorerEvent::EntitySelected {
id: key.to_string(),
});
}
}
}
TreeEvent::RowDoubleClicked(id) => {
let key = id.as_str();
if key == ROOT_KEY {
return;
}
if let Some(entry) = self.find_entry(key) {
if !matches!(entry.display_type, DisplayType::Folder) {
cx.emit(DatabaseExplorerEvent::EntityOpened {
id: key.to_string(),
});
}
}
}
TreeEvent::ContextMenuRequested { .. } | TreeEvent::ActiveChanged(_) => {}
}
}
fn find_entry(&self, id: &str) -> Option<&EntityNode> {
for entries in self.children.values() {
if let Some(e) = entries.iter().find(|e| e.id == id) {
return Some(e);
}
}
None
}
}
impl Render for DatabaseExplorer {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let pending_count = self.pending.len();
div()
.size_full()
.bg(theme.bg_panel.clone())
.flex()
.flex_col()
.child(
div()
.h(px(28.0))
.px(px(8.0))
.border_b_1()
.border_color(theme.border)
.flex()
.flex_row()
.items_center()
.gap(px(6.0))
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_muted)
.child("🗄️"),
)
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(self.db_path.clone())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(if pending_count > 0 {
format!("{}", pending_count)
} else {
String::new()
})),
),
)
.child(if let Some(err) = self.open_error.clone() {
// Si la DB no abrió, mostramos el error y no pintamos el
// árbol vacío — sería confuso.
div()
.p(px(12.0))
.text_size(px(11.0))
.text_color(theme.accent_strong)
.child(SharedString::from(format!("error abriendo DB: {}", err)))
} else {
div().flex_grow().min_h(px(0.0)).child(self.tree_view.clone())
})
}
}
fn sort_entries(entries: &mut Vec<EntityNode>) {
entries.sort_by(|a, b| {
let a_dir = matches!(a.display_type, DisplayType::Folder);
let b_dir = matches!(b.display_type, DisplayType::Folder);
b_dir
.cmp(&a_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
}