chore: monorepo inicial con arje + minga + yahweh absorbidos

Workspace en 4 ejes (core/modules/apps/shared):

- core/: 24 crates de arje (Init systemd-compatible: ente-card, ente-zero,
  ente-kernel, ente-bus, ente-cas, ente-soma, ente-wasm, ente-snapshot,
  ente-brain, ente-echo, ente-policy-provider, + 12 crates *-compat)
- modules/semantic_dht/: 5 crates de minga (minga-core con AST/CAS/MST,
  minga-p2p con libp2p Kad, minga-store, minga-vfs, minga-cli)
- modules/ui_engine/: 11 crates de yahweh (libs/{core,theme,bus,providers},
  widgets/{tree,splitter,tabs,tiled,container_core,text_input})
- apps/: 5 crates de yahweh (file_explorer, database_explorer, text_viewer,
  image_viewer, yahweh-shell)
- shared_wit/protocol.wit: handshake/lifecycle inicial

Cargo.toml unificado: thiserror bumped a 2 (transparente para arje), tokio
"full", paths intra-workspace de yahweh redirigidos a su nueva ubicación.

cargo check --workspace: 0 errores, 17 warnings (dead code preexistente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 04:45:44 +00:00
commit 53dbdf0f1d
176 changed files with 34845 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
//! Hot-reload del `layout.json` vía `notify` watcher.
//!
//! Anatomía:
//! 1. Un thread del SO corre el watcher (`notify::recommended_watcher`) que
//! spawnea su propio thread de polling. Cuando detecta cambios en el
//! archivo objetivo, manda `()` por un `std::sync::mpsc::channel`.
//! 2. Una task de gpui (`cx.spawn`) hace `try_recv` cada N ms (timer en el
//! `background_executor`). Si llega algo, relee el JSON y actualiza el
//! `LayoutModel` con `replace_tree`.
//!
//! Esquema separado intencional: notify trabaja en hilos del SO (no
//! integra con el executor de gpui), así que rebotamos vía mpsc para no
//! tocar entities desde threads ajenos. El tradeoff es una latencia de
//! poll N (250ms por default) — imperceptible para edición manual de un
//! JSON.
//!
//! Ignoramos cambios cuando el JSON quedó inválido (parse error) — el
//! `LayerConfig::load_or_default` cae al árbol default. Si querés que la
//! UI muestre el error, agregar un AppEvent::ConfigError y un toast en
//! Fase 8.
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
use gpui::{App, AsyncApp, Entity};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use yahweh_core::LayerConfig;
use crate::layout_model::LayoutModel;
/// Frecuencia de polling del receiver. 250ms es el sweet spot:
/// suficientemente rápido para sentirse "instantáneo" pero sin gastar CPU.
const POLL_INTERVAL: Duration = Duration::from_millis(250);
/// Spawnea el watcher + el polling task. Devuelve el `RecommendedWatcher`
/// — el caller debe mantenerlo vivo (drop ⇒ stop watching). Por
/// conveniencia retorna también nada más; el caller suele guardar el
/// watcher en una global o filed-leakeada.
pub fn spawn_watch(
path: PathBuf,
model: Entity<LayoutModel>,
cx: &mut App,
) -> notify::Result<RecommendedWatcher> {
let (tx, rx) = channel::<()>();
// Watcher: el cierre se ejecuta en el thread que `notify` provee. Solo
// empujamos `()` al canal — el side mpsc maneja toda la lógica.
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
// Solo nos interesan modify/create — los Access se ignoran
// para no triggerear en lecturas (ej. cat).
if matches!(
ev.kind,
notify::EventKind::Modify(_)
| notify::EventKind::Create(_)
| notify::EventKind::Remove(_)
) {
let _ = tx.send(());
}
}
})?;
// Watcheamos el directorio padre, no el archivo en sí. Muchos editores
// hacen "rename + create" al guardar (atomic write), lo que rompe
// watching del file directo. Ver el dir y filtrar por path es robusto.
let parent = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
watcher.watch(&parent, RecursiveMode::NonRecursive)?;
// Spawnea el polling task en el ForegroundExecutor para poder llamar
// model.update sin cross-thread issues.
let path_for_task = path.clone();
cx.foreground_executor()
.spawn(poll_loop(rx, path_for_task, model, cx.to_async()))
.detach();
Ok(watcher)
}
async fn poll_loop(
rx: Receiver<()>,
path: PathBuf,
model: Entity<LayoutModel>,
mut cx: AsyncApp,
) {
let timer = cx.background_executor().clone();
loop {
timer.timer(POLL_INTERVAL).await;
// Drenamos todos los eventos acumulados en este ciclo —
// múltiples writes seguidos colapsan a UN solo reload.
let mut had_event = false;
while rx.try_recv().is_ok() {
had_event = true;
}
if !had_event {
continue;
}
// Releemos el JSON desde disco. Si parsea bien, replace_tree;
// si no, el `load_or_default` cae al default (no rompe la UI).
let tree = LayerConfig::load_or_default(path.to_string_lossy().as_ref());
let _ = model.update(&mut cx, |m, cx| m.replace_tree(tree, cx));
}
}
+576
View File
@@ -0,0 +1,576 @@
//! `LayoutHost` — orquestador del layout dinámico.
//!
//! Lee un `LayerConfig` (raíz del JSON) y construye un árbol de entidades
//! GPUI dispatch-eando por `kind`:
//!
//! | kind | factory |
//! |-------------|----------------------------------------------|
//! | "Split" | `SplitContainer` con dirección + flex |
//! | "Tree" | `ManagedTree` con dataset stub |
//! | "Status" | `StatusPanel` |
//! | (otro) | placeholder textual con el kind y params |
//!
//! Cada entidad se memoiza por `NodeId` (string opcional del JSON o el path
//! sintético `root/child/0`). Mientras el `id` no cambie entre rebuilds, la
//! misma instancia se reusa — esto es lo que permite swappear el `kind` de
//! un container (Split → Tabs → Tiled) preservando los hijos sin reset.
//!
//! En Fase 3 el `LayoutHost` solo reconstruye al inicio. La observación del
//! `LayoutModel` (hot-reload del JSON, mutaciones desde la UI) entra en
//! Fase 7. Pero el diseño ya soporta `rebuild()` idempotente — se invoca
//! cuando el modelo cambia y la memoización mantiene los hijos vivos.
use std::collections::HashMap;
use gpui::{
AnyView, Context, Entity, IntoElement, Render, SharedString, Window, div, prelude::*,
};
use yahweh_bus::{AppBus, AppEvent};
use yahweh_core::{LayerConfig, LayoutDirection, NodeId};
use yahweh_database_explorer::{DatabaseExplorer, DatabaseExplorerEvent};
use yahweh_file_explorer::{FileExplorer, FileExplorerEvent};
use yahweh_image_viewer::ImageViewer;
use yahweh_text_viewer::TextViewer;
use yahweh_widget_container_core::ChildSlot;
use yahweh_widget_splitter::{SplitContainer, SplitEvent};
use yahweh_widget_tabs::TabContainer;
use yahweh_widget_tiled::{TiledContainer, TiledEvent};
use crate::layout_model::{LayoutModel, LayoutModelEvent};
use crate::managed_tree::ManagedTree;
use crate::persister::Persister;
use crate::status_panel::StatusPanel;
// =====================================================================
// LayoutHost
// =====================================================================
pub struct LayoutHost {
/// Modelo observable. Cualquier mutación (set_kind, replace_tree)
/// dispara un rebuild idempotente que preserva memoización.
model: Entity<LayoutModel>,
/// Bus app-level. Lo distribuimos a viewers (que se subscriben para
/// reaccionar a EntitySelected/Opened) y forwardeamos los eventos
/// tipados de explorers hacia él.
bus: Entity<AppBus>,
/// Persister adoptado — el LayoutHost mantiene su Entity vivo. Sin
/// strong handle el entity se dropea y la subscripción al model
/// queda inactiva.
#[allow(dead_code)]
persister: Entity<Persister>,
/// Memoización de instancias por NodeId. Cada slot guarda la entidad
/// tipada para poder llamarle métodos específicos (set_children en
/// containers, etc.). Se preserva entre rebuilds — eso es lo que
/// permite swappear kind del padre sin perder los hijos.
nodes: HashMap<NodeId, NodeSlot>,
/// La AnyView raíz, computada por el último `rebuild`. El render solo
/// pinta este handle.
root_view: Option<AnyView>,
}
/// Una entidad concreta instanciada en el árbol. Distinguimos por variante
/// porque cada tipo tiene una API distinta para reaccionar a actualizaciones
/// (set_children para Split, etc.). Las factories devuelven `AnyView`
/// directamente (`AnyView::from(entity.clone())`); esta enum solo guarda
/// la handle tipada para llamadas futuras.
enum NodeSlot {
Split(Entity<SplitContainer>),
Tabs(Entity<TabContainer>),
Tiled(Entity<TiledContainer>),
Tree(Entity<ManagedTree>),
FileExplorer(Entity<FileExplorer>),
DatabaseExplorer(Entity<DatabaseExplorer>),
TextViewer(Entity<TextViewer>),
ImageViewer(Entity<ImageViewer>),
Status(Entity<StatusPanel>),
Placeholder(Entity<PlaceholderView>),
}
impl LayoutHost {
pub fn new(
model: Entity<LayoutModel>,
bus: Entity<AppBus>,
persister: Entity<Persister>,
cx: &mut Context<Self>,
) -> Self {
// Subscripción event-filtered: solo rebuildeamos en cambios
// estructurales (set_kind, replace_tree). FlexChanged proviene de
// drags de divisor — el splitter ya tiene el flex aplicado en su
// Vec, rebuildear lo resetearía y rompería el drag.
cx.subscribe(&model, |this, _, ev: &LayoutModelEvent, cx| match ev {
LayoutModelEvent::StructureChanged => this.rebuild(cx),
LayoutModelEvent::FlexChanged => {}
})
.detach();
let mut me = Self {
model,
bus,
persister,
nodes: HashMap::new(),
root_view: None,
};
me.rebuild(cx);
me
}
/// Rebuild idempotente: walk del árbol del model, instancia (o reusa)
/// cada nodo, propaga children a los containers.
pub fn rebuild(&mut self, cx: &mut Context<Self>) {
// Snapshot del config para no chocar con el borrow al iterar +
// mutar self.nodes.
let cfg = self.model.read(cx).tree().clone();
let used_ids = std::cell::RefCell::new(Vec::new());
let view = self.build_node(&cfg, "root", &used_ids, cx);
// GC: tirar nodos cuyo id ya no aparece en el árbol nuevo.
let used: std::collections::HashSet<NodeId> =
used_ids.into_inner().into_iter().collect();
self.nodes.retain(|id, _| used.contains(id));
self.root_view = Some(view);
cx.notify();
}
/// DFS recursivo. `path` se acumula para los nodos que no traen `id`
/// propio en el JSON (`root/0/1` etc) — la sintetización vive en
/// `NodeId::from_layer`.
fn build_node(
&mut self,
cfg: &LayerConfig,
path: &str,
used_ids: &std::cell::RefCell<Vec<NodeId>>,
cx: &mut Context<Self>,
) -> AnyView {
let id = NodeId::from_layer(cfg, path);
used_ids.borrow_mut().push(id.clone());
match cfg.kind.as_str() {
"Split" => self.build_split(id, cfg, path, used_ids, cx),
"Tabs" => self.build_tabs(id, cfg, path, used_ids, cx),
"Tiled" => self.build_tiled(id, cfg, path, used_ids, cx),
"Tree" => self.build_tree(id, cfg, cx),
"FileExplorer" => self.build_file_explorer(id, cfg, cx),
"DatabaseExplorer" => self.build_database_explorer(id, cfg, cx),
"TextViewer" => self.build_text_viewer(id, cx),
"ImageViewer" => self.build_image_viewer(id, cx),
"Status" => self.build_status(id, cx),
_ => self.build_placeholder(id, cfg, cx),
}
}
/// Helper común — construye los `ChildSlot`s de un contenedor haciendo
/// recursión sobre los hijos del JSON. Usado por Split / Tabs / Tiled.
fn build_child_slots(
&mut self,
cfg: &LayerConfig,
path: &str,
used_ids: &std::cell::RefCell<Vec<NodeId>>,
cx: &mut Context<Self>,
) -> Vec<ChildSlot> {
let mut slots = Vec::with_capacity(cfg.children.len());
for (i, child) in cfg.children.iter().enumerate() {
let child_path = format!("{}/{}", path, i);
let child_view = self.build_node(child, &child_path, used_ids, cx);
slots.push(ChildSlot {
id: NodeId::from_layer(child, &child_path),
flex: child.flex_weight() as f32,
label: child.get_param("label").cloned(),
view: child_view,
});
}
slots
}
// ------- factories por kind -------
fn build_split(
&mut self,
id: NodeId,
cfg: &LayerConfig,
path: &str,
used_ids: &std::cell::RefCell<Vec<NodeId>>,
cx: &mut Context<Self>,
) -> AnyView {
let direction = match cfg.layout_direction() {
LayoutDirection::Vertical => LayoutDirection::Vertical,
LayoutDirection::Horizontal => LayoutDirection::Horizontal,
LayoutDirection::Overlay => LayoutDirection::Vertical, // fallback
};
// Get-or-create — si ya existe del rebuild anterior y es Split, lo
// reusamos. Si era de otro tipo, lo descartamos.
let entity = match self.nodes.get(&id) {
Some(NodeSlot::Split(e)) => e.clone(),
_ => {
let e = cx.new(|cx| SplitContainer::new(direction, cx));
// Suscripción a DragEnd para persistir flex al model.
// Usamos el id del Split como ancla para resolver children
// por id en el LayerConfig.
let model = self.model.clone();
let split_node_id = id.clone();
cx.subscribe(&e, move |_, split_entity, ev: &SplitEvent, cx| {
if !matches!(ev, SplitEvent::DragEnd) {
return;
}
// Snapshot de los flex actuales del splitter.
let snapshots: Vec<(NodeId, f32)> = split_entity
.read(cx)
.children()
.iter()
.map(|c| (c.id.clone(), c.flex))
.collect();
let _ = split_node_id; // (queda disponible si en
// futuro queremos targetear
// el padre directamente).
model.update(cx, |m, cx| {
for (child_id, flex) in snapshots {
m.set_flex(&child_id, flex, cx);
}
});
})
.detach();
self.nodes.insert(id.clone(), NodeSlot::Split(e.clone()));
e
}
};
// Sincronizamos la dirección por si el JSON cambió.
entity.update(cx, |s, cx| s.set_direction(direction, cx));
let slots = self.build_child_slots(cfg, path, used_ids, cx);
entity.update(cx, |s, cx| s.set_children(slots, cx));
AnyView::from(entity)
}
fn build_tabs(
&mut self,
id: NodeId,
cfg: &LayerConfig,
path: &str,
used_ids: &std::cell::RefCell<Vec<NodeId>>,
cx: &mut Context<Self>,
) -> AnyView {
let entity = match self.nodes.get(&id) {
Some(NodeSlot::Tabs(e)) => e.clone(),
_ => {
let e = cx.new(|cx| TabContainer::new(cx));
self.nodes.insert(id.clone(), NodeSlot::Tabs(e.clone()));
e
}
};
let slots = self.build_child_slots(cfg, path, used_ids, cx);
entity.update(cx, |s, cx| s.set_children(slots, cx));
AnyView::from(entity)
}
fn build_tiled(
&mut self,
id: NodeId,
cfg: &LayerConfig,
path: &str,
used_ids: &std::cell::RefCell<Vec<NodeId>>,
cx: &mut Context<Self>,
) -> AnyView {
let entity = match self.nodes.get(&id) {
Some(NodeSlot::Tiled(e)) => e.clone(),
_ => {
let e = cx.new(|cx| TiledContainer::new(cx));
// Drag-to-swap: el TiledContainer emite Reordered cuando
// un drag termina sobre otro tile. Lo commiteamos al
// model swappeando children del padre — el rebuild
// posterior aplicará el nuevo orden preservando los
// entities por NodeId.
let model = self.model.clone();
let parent_id = id.clone();
cx.subscribe(&e, move |_, _, ev: &TiledEvent, cx| match ev {
TiledEvent::Reordered {
from_index,
to_index,
..
} => {
let from = *from_index;
let to = *to_index;
let parent = parent_id.clone();
model.update(cx, |m, cx| m.swap_children(&parent, from, to, cx));
}
})
.detach();
self.nodes.insert(id.clone(), NodeSlot::Tiled(e.clone()));
e
}
};
let slots = self.build_child_slots(cfg, path, used_ids, cx);
entity.update(cx, |s, cx| s.set_children(slots, cx));
AnyView::from(entity)
}
fn build_tree(&mut self, id: NodeId, cfg: &LayerConfig, cx: &mut Context<Self>) -> AnyView {
// Param `dataset` selecciona el stub. Default: "sources".
let dataset = cfg
.get_param("dataset")
.cloned()
.unwrap_or_else(|| "sources".to_string());
let entity = match self.nodes.get(&id) {
Some(NodeSlot::Tree(e)) => e.clone(),
_ => {
let list_id = SharedString::from(format!("tree-{}", id));
let dataset_key = SharedString::from(dataset);
let e = cx.new(|cx| ManagedTree::new(list_id, dataset_key, cx));
self.nodes.insert(id.clone(), NodeSlot::Tree(e.clone()));
e
}
};
AnyView::from(entity)
}
fn build_file_explorer(
&mut self,
id: NodeId,
cfg: &LayerConfig,
cx: &mut Context<Self>,
) -> AnyView {
// Param `root` define el path inicial. Default: "." (cwd).
let root = cfg
.get_param("root")
.cloned()
.unwrap_or_else(|| ".".to_string());
let entity = match self.nodes.get(&id) {
Some(NodeSlot::FileExplorer(e)) => e.clone(),
_ => {
let e = cx.new(|cx| FileExplorer::new(root, cx));
// Forwarder: cuando el explorer emite eventos tipados, los
// traducimos al formato agnóstico del AppBus.
let bus = self.bus.clone();
cx.subscribe(&e, move |_, _, ev: &FileExplorerEvent, cx| {
let app_ev = match ev {
FileExplorerEvent::FileSelected { path } => {
Some(AppEvent::EntitySelected {
provider: "local_fs".to_string(),
provider_path: None,
id: path.clone(),
})
}
FileExplorerEvent::FileOpened { path } => {
Some(AppEvent::EntityOpened {
provider: "local_fs".to_string(),
provider_path: None,
id: path.clone(),
})
}
FileExplorerEvent::RootChanged { .. } => None,
};
if let Some(ev) = app_ev {
bus.update(cx, |_, cx| cx.emit(ev));
}
})
.detach();
self.nodes
.insert(id.clone(), NodeSlot::FileExplorer(e.clone()));
e
}
};
AnyView::from(entity)
}
fn build_database_explorer(
&mut self,
id: NodeId,
cfg: &LayerConfig,
cx: &mut Context<Self>,
) -> AnyView {
// Param `path` define el .sqlite. Default: "yahweh.db" en cwd.
let path = cfg
.get_param("path")
.cloned()
.unwrap_or_else(|| "yahweh.db".to_string());
let entity = match self.nodes.get(&id) {
Some(NodeSlot::DatabaseExplorer(e)) => e.clone(),
_ => {
let e = cx.new(|cx| DatabaseExplorer::new(path.clone(), cx));
// Forwarder al bus; el `provider_path` lleva el path del
// .sqlite para que el TextViewer pueda construir su propio
// SqliteDataProvider de la misma DB.
let bus = self.bus.clone();
let db_path = path.clone();
cx.subscribe(&e, move |_, _, ev: &DatabaseExplorerEvent, cx| {
let app_ev = match ev {
DatabaseExplorerEvent::EntitySelected { id } => {
Some(AppEvent::EntitySelected {
provider: "sqlite_db".to_string(),
provider_path: Some(db_path.clone()),
id: id.clone(),
})
}
DatabaseExplorerEvent::EntityOpened { id } => {
Some(AppEvent::EntityOpened {
provider: "sqlite_db".to_string(),
provider_path: Some(db_path.clone()),
id: id.clone(),
})
}
};
if let Some(ev) = app_ev {
bus.update(cx, |_, cx| cx.emit(ev));
}
})
.detach();
self.nodes
.insert(id.clone(), NodeSlot::DatabaseExplorer(e.clone()));
e
}
};
AnyView::from(entity)
}
fn build_text_viewer(&mut self, id: NodeId, cx: &mut Context<Self>) -> AnyView {
let entity = match self.nodes.get(&id) {
Some(NodeSlot::TextViewer(e)) => e.clone(),
_ => {
let bus = self.bus.clone();
let e = cx.new(|cx| TextViewer::new(bus, cx));
self.nodes
.insert(id.clone(), NodeSlot::TextViewer(e.clone()));
e
}
};
AnyView::from(entity)
}
fn build_image_viewer(&mut self, id: NodeId, cx: &mut Context<Self>) -> AnyView {
let entity = match self.nodes.get(&id) {
Some(NodeSlot::ImageViewer(e)) => e.clone(),
_ => {
let bus = self.bus.clone();
let e = cx.new(|cx| ImageViewer::new(bus, cx));
self.nodes
.insert(id.clone(), NodeSlot::ImageViewer(e.clone()));
e
}
};
AnyView::from(entity)
}
fn build_status(&mut self, id: NodeId, cx: &mut Context<Self>) -> AnyView {
let entity = match self.nodes.get(&id) {
Some(NodeSlot::Status(e)) => e.clone(),
_ => {
let model = self.model.clone();
let e = cx.new(|cx| StatusPanel::new(model, cx));
self.nodes.insert(id.clone(), NodeSlot::Status(e.clone()));
e
}
};
AnyView::from(entity)
}
fn build_placeholder(
&mut self,
id: NodeId,
cfg: &LayerConfig,
cx: &mut Context<Self>,
) -> AnyView {
// Si ya hay placeholder con esta id pero el kind cambió, lo
// recreamos para reflejar el nuevo `kind` en su mensaje.
let want_kind = cfg.kind.clone();
let create_new = match self.nodes.get(&id) {
Some(NodeSlot::Placeholder(e)) => {
let same_kind = e.read(cx).kind == want_kind;
!same_kind
}
_ => true,
};
if create_new {
let kind_clone = cfg.kind.clone();
let e = cx.new(|cx| PlaceholderView::new(kind_clone, cx));
self.nodes
.insert(id.clone(), NodeSlot::Placeholder(e.clone()));
return AnyView::from(e);
}
// Reuso.
if let Some(NodeSlot::Placeholder(e)) = self.nodes.get(&id) {
return AnyView::from(e.clone());
}
// Imposible llegar acá si la lógica de arriba está bien, pero
// mantenemos el fallback para no panicar en debug builds.
let kind_clone = cfg.kind.clone();
let e = cx.new(|cx| PlaceholderView::new(kind_clone, cx));
self.nodes.insert(id, NodeSlot::Placeholder(e.clone()));
AnyView::from(e)
}
}
impl Render for LayoutHost {
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
// En Fase 3 el árbol se construyó en `new` y queda fijo. Si
// root_view es None (ej. config vacío + GC barrió todo), pintamos
// un placeholder neutro.
match self.root_view.clone() {
Some(v) => div().size_full().child(v),
None => div()
.size_full()
.child("(layout vacío — revisar layout.json)"),
}
}
}
// =====================================================================
// PlaceholderView — kind no reconocido
// =====================================================================
/// View neutra que se instancia para cualquier `kind` que el LayoutHost no
/// sepa construir. Renderea el `kind` y los params para que sea evidente
/// qué falta implementar — útil mientras se desarrollan kinds nuevos
/// (FileExplorer, Tabs, Tiled, etc.).
pub struct PlaceholderView {
kind: String,
}
impl PlaceholderView {
pub fn new(kind: String, cx: &mut Context<Self>) -> Self {
cx.observe_global::<yahweh_theme::Theme>(|_, cx| cx.notify())
.detach();
Self { kind }
}
}
impl Render for PlaceholderView {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = yahweh_theme::Theme::global(cx).clone();
div()
.size_full()
.bg(theme.bg_panel.clone())
.p(gpui::px(16.0))
.flex()
.flex_col()
.gap(gpui::px(6.0))
.child(
div()
.text_color(theme.accent)
.text_size(gpui::px(14.0))
.child(SharedString::from(format!("⟨ kind: {}", self.kind))),
)
.child(
div()
.text_color(theme.fg_muted)
.text_size(gpui::px(11.0))
.child("(placeholder — kind no implementado todavía)"),
)
}
}
@@ -0,0 +1,140 @@
//! `LayoutModel` — fuente de verdad mutable del árbol de layout.
//!
//! Distinguimos dos clases de cambio:
//!
//! - [`LayoutModelEvent::StructureChanged`]: cambios que requieren rebuild
//! del árbol de entidades (kind, children, params relevantes). Estos
//! también invocan `cx.notify()` para que los `cx.observe` (StatusPanel,
//! etc.) refresquen.
//! - [`LayoutModelEvent::FlexChanged`]: actualización de `flex` de un
//! nodo (típicamente proviene de un drag de divisor). NO requiere
//! rebuild — el SplitContainer ya tiene el flex aplicado en su Vec; solo
//! nos importa para persistir. Por eso no llamamos `cx.notify()`: solo
//! emitimos el evento, así los `cx.observe` (que rebuilden) se mantienen
//! silenciosos durante el drag.
//!
//! El `Persister` (ver `shell/persister.rs`) se subscribe vía
//! `cx.subscribe` y reacciona a los dos.
use gpui::{Context, EventEmitter};
use yahweh_core::{LayerConfig, NodeId};
#[derive(Clone, Debug)]
pub enum LayoutModelEvent {
/// Estructural — kind / children / params. Triggerea rebuild en
/// `LayoutHost` y persist.
StructureChanged,
/// Solo flex de un nodo. Triggerea persist; NO rebuild.
FlexChanged,
}
pub struct LayoutModel {
tree: LayerConfig,
}
impl EventEmitter<LayoutModelEvent> for LayoutModel {}
impl LayoutModel {
pub fn new(tree: LayerConfig) -> Self {
Self { tree }
}
pub fn tree(&self) -> &LayerConfig {
&self.tree
}
/// Reemplazo completo del árbol — para hot-reload del JSON.
pub fn replace_tree(&mut self, tree: LayerConfig, cx: &mut Context<Self>) {
self.tree = tree;
cx.emit(LayoutModelEvent::StructureChanged);
cx.notify();
}
/// Cambia el `kind` del nodo cuyo id JSON coincide con `target_id`.
pub fn set_kind(
&mut self,
target_id: &NodeId,
new_kind: &str,
cx: &mut Context<Self>,
) {
let changed = mutate_node(&mut self.tree, target_id, &mut |node| {
if node.kind != new_kind {
node.kind = new_kind.to_string();
true
} else {
false
}
});
if changed {
cx.emit(LayoutModelEvent::StructureChanged);
cx.notify();
}
}
/// Setea el flex de un nodo. Solo emite `FlexChanged` (no notify) —
/// usado al final de un drag de divisor para persistir sin
/// triggerear rebuild.
pub fn set_flex(&mut self, target_id: &NodeId, flex: f32, cx: &mut Context<Self>) {
let new_val = Some(flex as f64);
let changed = mutate_node(&mut self.tree, target_id, &mut |node| {
if node.flex != new_val {
node.flex = new_val;
true
} else {
false
}
});
if changed {
cx.emit(LayoutModelEvent::FlexChanged);
}
}
/// Intercambia dos children del nodo `parent_id`. Triggerea
/// `StructureChanged` (rebuild + persist), porque cambia el orden de
/// instanciación. Si los índices son iguales o están out-of-bounds,
/// es no-op.
pub fn swap_children(
&mut self,
parent_id: &NodeId,
idx_a: usize,
idx_b: usize,
cx: &mut Context<Self>,
) {
if idx_a == idx_b {
return;
}
let mut did_swap = false;
mutate_node(&mut self.tree, parent_id, &mut |node| {
if idx_a < node.children.len() && idx_b < node.children.len() {
node.children.swap(idx_a, idx_b);
did_swap = true;
true
} else {
false
}
});
if did_swap {
cx.emit(LayoutModelEvent::StructureChanged);
cx.notify();
}
}
}
fn mutate_node(
node: &mut LayerConfig,
target: &NodeId,
f: &mut impl FnMut(&mut LayerConfig) -> bool,
) -> bool {
if let Some(id) = &node.id {
if id == target.as_str() {
return f(node);
}
}
for child in node.children.iter_mut() {
if mutate_node(child, target, f) {
return true;
}
}
false
}
+63
View File
@@ -0,0 +1,63 @@
//! Yahweh — bootstrap GPUI.
//!
//! Fase 6: además del LayoutModel, la Shell crea un `AppBus` (Entity) y se
//! lo pasa al LayoutHost. El bus circula a viewers (TextViewer,
//! ImageViewer) que se subscriben directo, y el LayoutHost forwardea los
//! eventos tipados de los explorers (FileExplorer, DatabaseExplorer)
//! traducidos a AppEvent.
mod hot_reload;
mod layout_host;
mod layout_model;
mod managed_tree;
mod persister;
mod status_panel;
use gpui::{App, Application, Bounds, WindowBounds, WindowOptions, prelude::*, px, size};
use yahweh_bus::AppBus;
use yahweh_core::LayerConfig;
use yahweh_theme::Theme;
use crate::layout_host::LayoutHost;
use crate::layout_model::LayoutModel;
use crate::persister::Persister;
const LAYOUT_PATH: &str = "layout.json";
fn main() {
Application::new().run(|cx: &mut App| {
Theme::install_default(cx);
let config = LayerConfig::load_or_default(LAYOUT_PATH);
let bounds = Bounds::centered(None, size(px(1300.), px(800.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_w, cx| {
let model = cx.new(|_| LayoutModel::new(config.clone()));
let bus = cx.new(|_| AppBus);
let persister = cx.new(|cx| {
Persister::new(LAYOUT_PATH.into(), model.clone(), cx)
});
// Hot-reload: notify watcher en el dir del JSON. El
// watcher debe mantenerse vivo (drop ⇒ stop), así que lo
// movemos a una static atómica vía Box::leak.
match hot_reload::spawn_watch(LAYOUT_PATH.into(), model.clone(), cx) {
Ok(watcher) => {
Box::leak(Box::new(watcher));
}
Err(e) => {
eprintln!("[hot_reload] no se pudo iniciar watcher: {}", e);
}
}
cx.new(|cx| LayoutHost::new(model, bus, persister, cx))
},
)
.unwrap();
cx.activate(true);
});
}
@@ -0,0 +1,287 @@
//! `ManagedTree` — wrapper de `TreeView` que aporta su propio modelo de
//! datos + estado de expansión. En Fase 3 sirve como stub data-driven (los
//! datasets se eligen vía param `dataset` del JSON). En Fase 4 este patrón
//! se concretiza en `FileExplorer` (TreeView + FsProvider) y
//! `DatabaseExplorer` (TreeView + SqliteProvider).
//!
//! La entidad es completamente Render-able y se entrega al LayoutHost como
//! un AnyView. Los TreeView events (RowClicked, ChevronToggled, …) se
//! traducen acá en cambios al estado de expansión y luego se re-emiten
//! como `ManagedTreeEvent` para que el host (LayoutHost o un App-bus
//! futuro) los consuma.
use std::collections::HashSet;
use gpui::{
Context, Entity, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*,
};
use yahweh_widget_tree::{RowId, RowKind, TreeEvent, TreeRow, TreeView};
// =====================================================================
// Datasets stub (Fase 3). En Fase 4 los reemplazan los providers reales.
// =====================================================================
#[derive(Clone)]
pub struct DemoNode {
pub id: &'static str,
pub label: &'static str,
pub icon: &'static str,
pub children: Vec<DemoNode>,
}
impl DemoNode {
fn leaf(id: &'static str, label: &'static str, icon: &'static str) -> Self {
Self { id, label, icon, children: vec![] }
}
fn branch(id: &'static str, label: &'static str, children: Vec<Self>) -> Self {
Self { id, label, icon: "📁", children }
}
}
/// Resuelve el dataset por `key` (proveniente del param `dataset` del JSON).
/// Cualquier key desconocida cae al stub vacío para no romper el render.
pub fn dataset_for(key: &str) -> DemoNode {
match key {
"sources" => yahweh_sources_tree(),
"deps" => yahweh_deps_tree(),
_ => DemoNode {
id: "unknown",
label: "(dataset desconocido)",
icon: "",
children: vec![],
},
}
}
fn yahweh_sources_tree() -> DemoNode {
DemoNode::branch(
"src-root",
"yahweh (src)",
vec![
DemoNode::branch(
"shell",
"shell",
vec![DemoNode::leaf("shell/main.rs", "main.rs", "📄")],
),
DemoNode::branch(
"widgets",
"widgets",
vec![
DemoNode::branch(
"widgets/tree",
"tree",
vec![DemoNode::leaf("widgets/tree/lib.rs", "lib.rs", "📄")],
),
DemoNode::branch(
"widgets/splitter",
"splitter",
vec![DemoNode::leaf("widgets/splitter/lib.rs", "lib.rs", "📄")],
),
],
),
DemoNode::branch(
"libs",
"libs",
vec![
DemoNode::branch(
"libs/core",
"core",
vec![DemoNode::leaf("libs/core/lib.rs", "lib.rs", "📄")],
),
DemoNode::branch(
"libs/theme",
"theme",
vec![DemoNode::leaf("libs/theme/lib.rs", "lib.rs", "📄")],
),
DemoNode::branch(
"libs/providers",
"providers",
vec![
DemoNode::leaf("libs/providers/fs.rs", "fs.rs", "📄"),
DemoNode::leaf("libs/providers/sqlite.rs", "sqlite.rs", "📄"),
],
),
],
),
],
)
}
fn yahweh_deps_tree() -> DemoNode {
fn branch(id: &'static str, label: &'static str, children: Vec<DemoNode>) -> DemoNode {
DemoNode { id, label, icon: "📦", children }
}
let leaf = DemoNode::leaf;
branch(
"deps-root",
"deps",
vec![
branch(
"ui",
"ui",
vec![
leaf("dep:gpui", "gpui 0.2.2", "🧊"),
leaf("dep:gpui-macros", "gpui-macros 0.2.2", "🧊"),
],
),
branch(
"async",
"async",
vec![
leaf("dep:tokio", "tokio 1.x", "🌀"),
leaf("dep:async-trait", "async-trait 0.1", "🌀"),
],
),
branch(
"data",
"data",
vec![
leaf("dep:serde", "serde 1", "🧬"),
leaf("dep:serde_json", "serde_json 1", "🧬"),
leaf("dep:rusqlite", "rusqlite 0.31", "🗃️"),
],
),
leaf("dep:notify", "notify 6.1", "👂"),
leaf("dep:uuid", "uuid 1", "🔗"),
],
)
}
// =====================================================================
// Eventos re-emitidos
// =====================================================================
/// Re-emitidos por ManagedTree después de procesar el evento bruto del
/// TreeView interno. Contienen el `dataset_key` para que el host distinga
/// entre múltiples ManagedTrees.
///
/// Los campos están marcados `dead_code`-ok porque en Fase 3 nadie se
/// suscribe — el LayoutHost los va a consumir en Fase 4 vía un AppBus que
/// reenvíe estos eventos a los viewers (TextViewer / ImageViewer / etc).
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum ManagedTreeEvent {
RowClicked { dataset: SharedString, id: String },
RowDoubleClicked { dataset: SharedString, id: String },
ContextMenu { dataset: SharedString, id: Option<String> },
}
// =====================================================================
// Widget
// =====================================================================
pub struct ManagedTree {
view: Entity<TreeView>,
data: DemoNode,
expanded: HashSet<String>,
dataset_key: SharedString,
}
impl EventEmitter<ManagedTreeEvent> for ManagedTree {}
impl ManagedTree {
pub fn new(
list_id: SharedString,
dataset_key: SharedString,
cx: &mut Context<Self>,
) -> Self {
let view = cx.new(|cx| TreeView::new(list_id, cx));
cx.subscribe(&view, |this: &mut ManagedTree, _, ev, cx| {
this.on_tree_event(ev, cx);
})
.detach();
let data = dataset_for(&dataset_key);
let mut expanded = HashSet::new();
expanded.insert(data.id.to_string());
let me = Self {
view,
data,
expanded,
dataset_key,
};
me.push_rows(cx);
me
}
fn push_rows(&self, cx: &mut Context<Self>) {
let mut rows = Vec::new();
flatten(&self.data, 0, &self.expanded, &mut rows);
self.view.update(cx, |tree, cx| tree.set_rows(rows, cx));
}
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);
}
self.push_rows(cx);
}
TreeEvent::RowClicked(id) => {
cx.emit(ManagedTreeEvent::RowClicked {
dataset: self.dataset_key.clone(),
id: id.to_string(),
});
}
TreeEvent::RowDoubleClicked(id) => {
cx.emit(ManagedTreeEvent::RowDoubleClicked {
dataset: self.dataset_key.clone(),
id: id.to_string(),
});
}
TreeEvent::ContextMenuRequested { id, .. } => {
cx.emit(ManagedTreeEvent::ContextMenu {
dataset: self.dataset_key.clone(),
id: id.as_ref().map(|i| i.to_string()),
});
}
TreeEvent::ActiveChanged(_) => {}
}
}
/// Reservado para Fase 4: el LayoutHost lo va a consultar al
/// re-emitir eventos al bus.
#[allow(dead_code)]
pub fn dataset_key(&self) -> &str {
&self.dataset_key
}
}
impl Render for ManagedTree {
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.view.clone())
}
}
// -------- helpers --------
fn flatten(
node: &DemoNode,
depth: u32,
expanded: &HashSet<String>,
out: &mut Vec<TreeRow>,
) {
let kind = if node.children.is_empty() {
RowKind::Leaf
} else {
RowKind::Branch
};
let is_expanded = expanded.contains(node.id);
out.push(TreeRow {
id: RowId::new(node.id),
label: node.label.to_string(),
depth,
kind,
expanded: is_expanded,
icon: Some(node.icon.to_string()),
});
if is_expanded {
for child in &node.children {
flatten(child, depth + 1, expanded, out);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
//! `Persister` — escribe el `LayoutModel` a disco en cada cambio.
//!
//! Es una entity sin estado visible (no se renderea). Solo existe para
//! mantener viva la subscripción al `LayoutModel`. Cualquier evento
//! (`StructureChanged` o `FlexChanged`) dispara una escritura sincrónica
//! al `path` configurado.
//!
//! Hoy NO hay debounce — cada drag de divisor emite UN solo `FlexChanged`
//! al final (en DragEnd, no por frame), y los swaps de kind son acción
//! manual del usuario. Si en el futuro las escrituras se vuelven
//! frecuentes, el lugar para sumar debounce es acá: spawn un task que
//! coalesce events dentro de N ms.
use std::path::PathBuf;
use gpui::{Context, Entity};
use crate::layout_model::{LayoutModel, LayoutModelEvent};
pub struct Persister {
path: PathBuf,
}
impl Persister {
pub fn new(path: PathBuf, model: Entity<LayoutModel>, cx: &mut Context<Self>) -> Self {
cx.subscribe(&model, |this: &mut Persister, model, _ev: &LayoutModelEvent, cx| {
this.write(model.read(cx).tree());
})
.detach();
Self { path }
}
fn write(&self, tree: &yahweh_core::LayerConfig) {
let json = tree.serialize_json();
// Anti-loop: si el contenido en disco ya coincide, skip. Esto
// matters cuando el watcher está corriendo: persister write →
// notify modify → replace_tree → persister write → ... sin esto
// sería un loop infinito de syscalls.
if let Ok(existing) = std::fs::read_to_string(&self.path) {
if existing == json {
return;
}
}
if let Err(e) = std::fs::write(&self.path, json) {
eprintln!(
"[Persister] error escribiendo {}: {}",
self.path.display(),
e
);
}
}
}
@@ -0,0 +1,343 @@
//! `StatusPanel` — panel lateral con info de la app, switcher de tema y
//! controles de swap del kind del contenedor objetivo (Fase 5).
//!
//! Recibe el `Entity<LayoutModel>` para mutarlo: cuando el usuario clickea
//! "Tabs", el StatusPanel hace
//! `model.update(cx, |m, cx| m.set_kind(target_id, "Tabs", cx))`. El
//! LayoutHost está suscripto al model y rebuildea preservando los hijos
//! del contenedor por NodeId — eso es lo que demuestra que swappear el
//! contenedor no resetea el contenido (las pestañas activas, expansiones
//! del FileExplorer, scroll position, todo se mantiene).
use gpui::{
ClickEvent, Context, Entity, IntoElement, Render, SharedString, Window, div, prelude::*, px,
};
use yahweh_core::{LayerConfig, NodeId};
use yahweh_theme::Theme;
use crate::layout_model::LayoutModel;
/// Id del contenedor que el StatusPanel controla con sus botones de swap.
/// Hardcoded por ahora; en el futuro lo podríamos leer de un param del
/// JSON (e.g. `target_id`) para que cualquier StatusPanel apunte al
/// container que querramos.
const SWAP_TARGET_ID: &str = "explorers";
const SWAPPABLE_KINDS: &[&str] = &["Split", "Tabs", "Tiled"];
/// Path del JSON que el botón "Reload" relee. Mantenemos consistencia con
/// `main.rs` — si llegamos a parametrizarlo, hacerlo en un solo lugar.
const LAYOUT_PATH: &str = "layout.json";
pub struct StatusPanel {
model: Entity<LayoutModel>,
last_event: SharedString,
}
impl StatusPanel {
pub fn new(model: Entity<LayoutModel>, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
// Subscripción al model para refrescar el "kind activo" del swap.
cx.observe(&model, |_, _, cx| cx.notify()).detach();
Self {
model,
last_event: "(esperando bus app-level — Fase 6)".into(),
}
}
/// Reservado para Fase 6: el AppBus va a actualizar este texto.
#[allow(dead_code)]
pub fn set_status(&mut self, text: SharedString, cx: &mut Context<Self>) {
self.last_event = text;
cx.notify();
}
fn cycle_theme(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
let current = Theme::global(cx).name.to_string();
Theme::set(cx, Theme::next_after(&current));
cx.notify();
}
fn pick_theme(
&mut self,
name: SharedString,
_: &ClickEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(theme) = Theme::by_name(&name) {
Theme::set(cx, theme);
cx.notify();
}
}
fn swap_container(
&mut self,
kind: SharedString,
_: &ClickEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
let target = NodeId::new(SWAP_TARGET_ID);
self.model.update(cx, |m, cx| {
m.set_kind(&target, &kind, cx);
});
}
fn reload_from_disk(
&mut self,
_: &ClickEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
// Releemos el JSON. Si el parsing falla, `load_or_default` cae al
// árbol default — no panicamos. La preservación de hijos
// funciona vía el id JSON: lo que matchee, persiste; lo nuevo se
// instancia.
let tree = LayerConfig::load_or_default(LAYOUT_PATH);
self.model.update(cx, |m, cx| m.replace_tree(tree, cx));
}
/// Lee el `kind` actual del contenedor objetivo desde el model. Si el
/// id no existe (alguien cambió el JSON), devuelve `None` y no
/// resaltamos ningún chip.
fn current_target_kind(&self, cx: &Context<Self>) -> Option<String> {
find_kind(self.model.read(cx).tree(), SWAP_TARGET_ID)
}
}
fn find_kind(node: &yahweh_core::LayerConfig, target: &str) -> Option<String> {
if let Some(id) = &node.id {
if id == target {
return Some(node.kind.clone());
}
}
for child in &node.children {
if let Some(k) = find_kind(child, target) {
return Some(k);
}
}
None
}
impl Render for StatusPanel {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let active_theme = theme.name;
let theme_chips: Vec<_> = Theme::all()
.into_iter()
.map(|t| (t.name, t.name == active_theme))
.collect();
let current_kind = self.current_target_kind(cx);
// Theme chips.
let mut theme_row = div().flex().flex_row().flex_wrap().gap(px(6.0));
for (name, is_active) in theme_chips {
let bg = if is_active {
theme.bg_row_active
} else {
theme.bg_row_hover
};
let border = if is_active {
theme.accent_strong
} else {
theme.border
};
let chip_id = SharedString::from(format!("theme-chip-{}", name));
let chip_name = SharedString::from(name.to_string());
theme_row = theme_row.child(
div()
.id(chip_id)
.px(px(10.0))
.py(px(5.0))
.rounded(px(4.0))
.border_1()
.border_color(border)
.bg(bg)
.text_size(px(11.0))
.text_color(theme.fg_text)
.hover(|s| s.opacity(0.85))
.child(SharedString::from(name.to_string()))
.on_click(cx.listener(move |this, click, w, cx| {
this.pick_theme(chip_name.clone(), click, w, cx);
})),
);
}
// Swap chips — uno por kind (Split / Tabs / Tiled). El activo
// refleja el `kind` actual del nodo objetivo.
let mut swap_row = div().flex().flex_row().gap(px(6.0));
for &kind in SWAPPABLE_KINDS {
let is_active = current_kind.as_deref() == Some(kind);
let bg = if is_active {
theme.bg_row_active
} else {
theme.bg_row_hover
};
let border = if is_active {
theme.accent_strong
} else {
theme.border
};
let chip_id = SharedString::from(format!("swap-chip-{}", kind));
let kind_str = SharedString::from(kind.to_string());
swap_row = swap_row.child(
div()
.id(chip_id)
.px(px(12.0))
.py(px(6.0))
.rounded(px(4.0))
.border_1()
.border_color(border)
.bg(bg)
.text_size(px(12.0))
.text_color(theme.fg_text)
.hover(|s| s.opacity(0.85))
.child(SharedString::from(kind.to_string()))
.on_click(cx.listener(move |this, click, w, cx| {
this.swap_container(kind_str.clone(), click, w, cx);
})),
);
}
div()
.size_full()
.bg(theme.bg_panel_alt.clone())
.p(px(20.0))
.flex()
.flex_col()
.gap(px(14.0))
.child(
div()
.text_size(px(22.0))
.text_color(theme.accent_strong)
.child("Yahweh — Fase 5"),
)
.child(
div()
.text_color(theme.fg_muted)
.text_size(px(12.0))
.child("Contenedores intercambiables sin perder hijos."),
)
// ----- persistencia + reload -----
.child(
div()
.mt(px(14.0))
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child("layout.json:"),
)
.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(
"auto-save al swap o al soltar un divisor.",
),
)
.child(
div()
.id("reload-from-disk")
.px(px(10.0))
.py(px(4.0))
.rounded(px(4.0))
.border_1()
.border_color(theme.border_strong)
.bg(theme.bg_panel.clone())
.text_color(theme.fg_text)
.text_size(px(11.0))
.hover(|s| s.opacity(0.85))
.on_click(cx.listener(Self::reload_from_disk))
.child("⤓ reload"),
),
)
// ----- swap del contenedor 'explorers' -----
.child(
div()
.mt(px(14.0))
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child(SharedString::from(format!(
"contenedor '{}' — kind:",
SWAP_TARGET_ID
))),
)
.child(swap_row)
.child(
div()
.mt(px(2.0))
.text_color(theme.fg_muted)
.text_size(px(10.0))
.child(
"click ⇒ swappea el kind del contenedor padre. Los hijos \
(FileExplorer, DatabaseExplorer) se preservan: cualquier \
folder expandido o entry seleccionado sigue así tras el swap.",
),
)
// ----- log de evento (placeholder Fase 6) -----
.child(
div()
.mt(px(16.0))
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child("último evento (bus Fase 6):"),
)
.child(
div()
.px(px(10.0))
.py(px(8.0))
.bg(theme.bg_panel.clone())
.border_1()
.border_color(theme.border)
.rounded(px(4.0))
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(self.last_event.clone()),
)
// ----- theme switcher -----
.child(
div()
.mt(px(20.0))
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child("tema activo:"),
)
.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(10.0))
.child(
div()
.text_color(theme.accent)
.text_size(px(15.0))
.child(SharedString::from(theme.name.to_string())),
)
.child(
div()
.id("cycle-theme")
.px(px(10.0))
.py(px(4.0))
.rounded(px(4.0))
.border_1()
.border_color(theme.border_strong)
.bg(theme.bg_panel.clone())
.text_color(theme.fg_text)
.text_size(px(11.0))
.hover(|s| s.opacity(0.85))
.on_click(cx.listener(Self::cycle_theme))
.child("⇄ siguiente"),
),
)
.child(theme_row)
}
}