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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t));
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user