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,13 @@
|
||||
[package]
|
||||
name = "yahweh-database-explorer"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Explorer de SQLite — composición TreeView + SqliteProvider con lazy load."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-core = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
yahweh-widget-tree = { workspace = true }
|
||||
yahweh-provider-sqlite = { workspace = true }
|
||||
@@ -0,0 +1,284 @@
|
||||
//! `yahweh_database_explorer` — explorer de SQLite.
|
||||
//!
|
||||
//! Mismo patrón que `yahweh_file_explorer` pero con `SqliteProvider`. La
|
||||
//! UX es idéntica (TreeView con lazy load por chevron); cambia solo el
|
||||
//! origen de los datos: filas de una tabla `items(id, parent_id, name,
|
||||
//! display_type, content)` en lugar del filesystem.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
Context, Entity, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*,
|
||||
px,
|
||||
};
|
||||
|
||||
use yahweh_core::{DataProvider, DisplayType, EntityNode};
|
||||
use yahweh_provider_sqlite::SqliteDataProvider;
|
||||
use yahweh_theme::Theme;
|
||||
use yahweh_widget_tree::{RowId, RowKind, TreeEvent, TreeRow, TreeView};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(dead_code)] // Consumido por el AppBus en Fase 4+.
|
||||
pub enum DatabaseExplorerEvent {
|
||||
EntitySelected { id: String },
|
||||
EntityOpened { id: String },
|
||||
}
|
||||
|
||||
pub struct DatabaseExplorer {
|
||||
tree_view: Entity<TreeView>,
|
||||
provider: Arc<SqliteDataProvider>,
|
||||
db_path: String,
|
||||
|
||||
expanded: HashSet<String>,
|
||||
children: HashMap<String, Vec<EntityNode>>,
|
||||
pending: HashSet<String>,
|
||||
/// Mensaje de error si la DB no abrió. Se muestra en el header.
|
||||
open_error: Option<String>,
|
||||
}
|
||||
|
||||
const ROOT_KEY: &str = "__db_root__";
|
||||
|
||||
impl EventEmitter<DatabaseExplorerEvent> for DatabaseExplorer {}
|
||||
|
||||
impl DatabaseExplorer {
|
||||
/// `db_path` es la ruta al .sqlite. Si no existe se crea con la tabla
|
||||
/// `items` y un seed mínimo (ver `SqliteDataProvider::new`).
|
||||
pub fn new(db_path: String, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
|
||||
let tree_view = cx.new(|cx| TreeView::new("db-explorer-tree", cx));
|
||||
cx.subscribe(&tree_view, |this: &mut DatabaseExplorer, _, ev, cx| {
|
||||
this.on_tree_event(ev, cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
let (provider, open_error) = match SqliteDataProvider::new(&db_path) {
|
||||
Ok(p) => (Some(Arc::new(p)), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
|
||||
let mut expanded = HashSet::new();
|
||||
expanded.insert(ROOT_KEY.to_string());
|
||||
|
||||
let mut me = Self {
|
||||
tree_view,
|
||||
// Usamos un dummy provider si la DB no abrió. La UI mostrará el
|
||||
// error en el header; cualquier load_children retornará vacío.
|
||||
provider: provider.unwrap_or_else(|| {
|
||||
Arc::new(SqliteDataProvider::new(":memory:").expect("memory db"))
|
||||
}),
|
||||
db_path,
|
||||
expanded,
|
||||
children: HashMap::new(),
|
||||
pending: HashSet::new(),
|
||||
open_error,
|
||||
};
|
||||
// Cargar el root (parent_id NULL en SQLite, mapped to None acá).
|
||||
me.load_children(ROOT_KEY.to_string(), cx);
|
||||
me
|
||||
}
|
||||
|
||||
pub fn db_path(&self) -> &str {
|
||||
&self.db_path
|
||||
}
|
||||
|
||||
fn load_children(&mut self, parent_key: String, cx: &mut Context<Self>) {
|
||||
if self.pending.contains(&parent_key) || self.children.contains_key(&parent_key) {
|
||||
return;
|
||||
}
|
||||
self.pending.insert(parent_key.clone());
|
||||
|
||||
let provider = self.provider.clone();
|
||||
let parent_for_task = parent_key.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
// ROOT_KEY → None; cualquier otro → Some(actual id).
|
||||
let arg: Option<String> = if parent_for_task == ROOT_KEY {
|
||||
None
|
||||
} else {
|
||||
Some(parent_for_task.clone())
|
||||
};
|
||||
let result = provider.list_children(arg.as_deref()).await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.on_children_loaded(parent_for_task, result, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn on_children_loaded(
|
||||
&mut self,
|
||||
parent_key: String,
|
||||
result: Result<Vec<EntityNode>, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.pending.remove(&parent_key);
|
||||
let mut entries = result.unwrap_or_default();
|
||||
sort_entries(&mut entries);
|
||||
self.children.insert(parent_key, entries);
|
||||
self.push_rows(cx);
|
||||
}
|
||||
|
||||
fn push_rows(&self, cx: &mut Context<Self>) {
|
||||
let mut rows = Vec::new();
|
||||
// Una row "raíz virtual" para que el árbol tenga un anchor visible.
|
||||
rows.push(TreeRow {
|
||||
id: RowId::new(ROOT_KEY),
|
||||
label: format!("(db) {}", self.db_path),
|
||||
depth: 0,
|
||||
kind: RowKind::Branch,
|
||||
expanded: self.expanded.contains(ROOT_KEY),
|
||||
icon: Some("🗄️".to_string()),
|
||||
});
|
||||
if self.expanded.contains(ROOT_KEY) {
|
||||
self.append_children(ROOT_KEY, 1, &mut rows);
|
||||
}
|
||||
|
||||
self.tree_view
|
||||
.update(&mut *cx, |tree, cx| tree.set_rows(rows, cx));
|
||||
}
|
||||
|
||||
fn append_children(&self, parent: &str, depth: u32, out: &mut Vec<TreeRow>) {
|
||||
let Some(children) = self.children.get(parent) else { return };
|
||||
for entry in children {
|
||||
let kind = match entry.display_type {
|
||||
DisplayType::Folder => RowKind::Branch,
|
||||
_ => RowKind::Leaf,
|
||||
};
|
||||
let icon = match entry.display_type {
|
||||
DisplayType::Folder => "📂",
|
||||
DisplayType::File => "📄",
|
||||
DisplayType::Stream => "📡",
|
||||
};
|
||||
let is_expanded = self.expanded.contains(&entry.id);
|
||||
out.push(TreeRow {
|
||||
id: RowId::new(entry.id.clone()),
|
||||
label: entry.name.clone(),
|
||||
depth,
|
||||
kind,
|
||||
expanded: is_expanded,
|
||||
icon: Some(icon.to_string()),
|
||||
});
|
||||
if is_expanded {
|
||||
self.append_children(&entry.id, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_tree_event(&mut self, event: &TreeEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
TreeEvent::ChevronToggled(id) => {
|
||||
let key = id.as_str().to_string();
|
||||
if !self.expanded.remove(&key) {
|
||||
self.expanded.insert(key.clone());
|
||||
self.load_children(key, cx);
|
||||
}
|
||||
self.push_rows(cx);
|
||||
}
|
||||
TreeEvent::RowClicked(id) => {
|
||||
let key = id.as_str();
|
||||
if key == ROOT_KEY {
|
||||
return;
|
||||
}
|
||||
if let Some(entry) = self.find_entry(key) {
|
||||
if !matches!(entry.display_type, DisplayType::Folder) {
|
||||
cx.emit(DatabaseExplorerEvent::EntitySelected {
|
||||
id: key.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
TreeEvent::RowDoubleClicked(id) => {
|
||||
let key = id.as_str();
|
||||
if key == ROOT_KEY {
|
||||
return;
|
||||
}
|
||||
if let Some(entry) = self.find_entry(key) {
|
||||
if !matches!(entry.display_type, DisplayType::Folder) {
|
||||
cx.emit(DatabaseExplorerEvent::EntityOpened {
|
||||
id: key.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
TreeEvent::ContextMenuRequested { .. } | TreeEvent::ActiveChanged(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_entry(&self, id: &str) -> Option<&EntityNode> {
|
||||
for entries in self.children.values() {
|
||||
if let Some(e) = entries.iter().find(|e| e.id == id) {
|
||||
return Some(e);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DatabaseExplorer {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let pending_count = self.pending.len();
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.bg(theme.bg_panel.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.h(px(28.0))
|
||||
.px(px(8.0))
|
||||
.border_b_1()
|
||||
.border_color(theme.border)
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.gap(px(6.0))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child("🗄️"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child(SharedString::from(self.db_path.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.ml_auto()
|
||||
.text_size(px(10.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child(SharedString::from(if pending_count > 0 {
|
||||
format!("⏳ {}", pending_count)
|
||||
} else {
|
||||
String::new()
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(if let Some(err) = self.open_error.clone() {
|
||||
// Si la DB no abrió, mostramos el error y no pintamos el
|
||||
// árbol vacío — sería confuso.
|
||||
div()
|
||||
.p(px(12.0))
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.accent_strong)
|
||||
.child(SharedString::from(format!("error abriendo DB: {}", err)))
|
||||
} else {
|
||||
div().flex_grow().min_h(px(0.0)).child(self.tree_view.clone())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_entries(entries: &mut Vec<EntityNode>) {
|
||||
entries.sort_by(|a, b| {
|
||||
let a_dir = matches!(a.display_type, DisplayType::Folder);
|
||||
let b_dir = matches!(b.display_type, DisplayType::Folder);
|
||||
b_dir
|
||||
.cmp(&a_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "yahweh-file-explorer"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Explorer de filesystem — composición TreeView + FsProvider con lazy load."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-core = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
yahweh-widget-tree = { workspace = true }
|
||||
yahweh-widget-text-input = { workspace = true }
|
||||
yahweh-provider-fs = { workspace = true }
|
||||
@@ -0,0 +1,786 @@
|
||||
//! `yahweh_file_explorer` — explorer de filesystem con menú contextual.
|
||||
//!
|
||||
//! Composición canónica del patrón "explorer = TreeView + provider":
|
||||
//!
|
||||
//! ```text
|
||||
//! FileExplorer
|
||||
//! ├── TreeView (widgets/tree, agnóstico)
|
||||
//! └── FsProvider (libs/providers/fs, async + tokio::io)
|
||||
//! ```
|
||||
//!
|
||||
//! Estado:
|
||||
//! - `expanded`: set de paths cuyo chevron está abierto.
|
||||
//! - `children`: cache de hijos por path parent (cargado lazy via
|
||||
//! `cx.spawn` + provider).
|
||||
//! - `pending`: set de loads en vuelo (anti re-trigger).
|
||||
//! - `menu`: estado del menú contextual flotante (Fase 4.5).
|
||||
//!
|
||||
//! Menú contextual (Fase 4.5):
|
||||
//! Right-click sobre una fila o el área vacía del árbol abre un menú con
|
||||
//! las acciones FS habituales. Las mutaciones se ejecutan sincrónicamente
|
||||
//! contra `std::fs` y luego se invalida el cache del directorio afectado
|
||||
//! para forzar reload del provider.
|
||||
//!
|
||||
//! Acciones soportadas:
|
||||
//! - **Open** (solo file): emite `FileExplorerEvent::FileOpened` (que el
|
||||
//! forwarder de la Shell traduce a `AppEvent::EntityOpened` al bus).
|
||||
//! - **Copy path**: copia el path absoluto al clipboard.
|
||||
//! - **New file**: crea un archivo vacío con nombre auto-generado
|
||||
//! (`new_file_NN.txt`) en el directorio elegido — sin necesitar text
|
||||
//! input por ahora; renombrar viene cuando sumemos un TextInput
|
||||
//! widget.
|
||||
//! - **New folder**: crea un directorio con nombre auto (`new_folder_NN`).
|
||||
//! - **Delete**: confirmación vía `window.prompt` (dialog del platform);
|
||||
//! si el usuario confirma, `remove_file` o `remove_dir_all` según
|
||||
//! corresponda.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
ClickEvent, ClipboardItem, Context, Entity, EventEmitter, IntoElement, Pixels, Point,
|
||||
PromptLevel, Render, SharedString, Window, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use yahweh_core::{DataProvider, DisplayType, EntityNode};
|
||||
use yahweh_provider_fs::FileDataProvider;
|
||||
use yahweh_theme::Theme;
|
||||
use yahweh_widget_text_input::{TextInput, TextInputEvent};
|
||||
use yahweh_widget_tree::{RowId, RowKind, TreeEvent, TreeRow, TreeView};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum FileExplorerEvent {
|
||||
FileSelected { path: String },
|
||||
FileOpened { path: String },
|
||||
RootChanged { path: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct MenuState {
|
||||
/// `None` ⇒ menú "fondo" (área vacía del tree). `Some` ⇒ sobre una
|
||||
/// fila concreta.
|
||||
target: Option<MenuTarget>,
|
||||
/// Posición absoluta donde se hizo el right-click. Para el overlay
|
||||
/// usamos coords window-relative (gpui las maneja como px).
|
||||
position: Point<Pixels>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct MenuTarget {
|
||||
id: String,
|
||||
is_folder: bool,
|
||||
}
|
||||
|
||||
pub struct FileExplorer {
|
||||
tree_view: Entity<TreeView>,
|
||||
provider: Arc<FileDataProvider>,
|
||||
|
||||
root: String,
|
||||
expanded: HashSet<String>,
|
||||
children: HashMap<String, Vec<EntityNode>>,
|
||||
pending: HashSet<String>,
|
||||
menu: Option<MenuState>,
|
||||
/// Modal de rename activo. `None` ⇒ no hay modal.
|
||||
rename: Option<RenameState>,
|
||||
}
|
||||
|
||||
/// Estado del modal de rename. El TextInput vive como sub-entity para
|
||||
/// que pueda recibir focus + key events. El target_path lo lleva la
|
||||
/// closure de subscripción (no lo necesitamos en `self` aparte).
|
||||
#[derive(Clone)]
|
||||
struct RenameState {
|
||||
original_name: String,
|
||||
input: Entity<TextInput>,
|
||||
}
|
||||
|
||||
impl EventEmitter<FileExplorerEvent> for FileExplorer {}
|
||||
|
||||
impl FileExplorer {
|
||||
pub fn new(root: String, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
|
||||
let tree_view = cx.new(|cx| TreeView::new("file-explorer-tree", cx));
|
||||
cx.subscribe(&tree_view, |this: &mut FileExplorer, _, ev, cx| {
|
||||
this.on_tree_event(ev, cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
let mut expanded = HashSet::new();
|
||||
expanded.insert(root.clone());
|
||||
|
||||
let mut me = Self {
|
||||
tree_view,
|
||||
provider: Arc::new(FileDataProvider),
|
||||
root: root.clone(),
|
||||
expanded,
|
||||
children: HashMap::new(),
|
||||
pending: HashSet::new(),
|
||||
menu: None,
|
||||
rename: None,
|
||||
};
|
||||
me.load_children(root, cx);
|
||||
me
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &str {
|
||||
&self.root
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_root(&mut self, path: String, cx: &mut Context<Self>) {
|
||||
if path != self.root {
|
||||
self.root = path.clone();
|
||||
self.expanded.insert(path.clone());
|
||||
self.load_children(path.clone(), cx);
|
||||
self.push_rows(cx);
|
||||
cx.emit(FileExplorerEvent::RootChanged { path });
|
||||
}
|
||||
}
|
||||
|
||||
// ----- load + cache -----
|
||||
|
||||
fn load_children(&mut self, parent: String, cx: &mut Context<Self>) {
|
||||
if self.pending.contains(&parent) || self.children.contains_key(&parent) {
|
||||
return;
|
||||
}
|
||||
self.pending.insert(parent.clone());
|
||||
|
||||
let provider = self.provider.clone();
|
||||
let parent_for_task = parent.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = provider.list_children(Some(&parent_for_task)).await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.on_children_loaded(parent_for_task, result, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Versión "force": invalida el cache antes de re-pedir. Se usa tras
|
||||
/// una mutación FS (new/delete) para que la UI refleje el cambio.
|
||||
fn refresh_dir(&mut self, parent: String, cx: &mut Context<Self>) {
|
||||
self.children.remove(&parent);
|
||||
self.pending.remove(&parent);
|
||||
self.load_children(parent, cx);
|
||||
}
|
||||
|
||||
fn on_children_loaded(
|
||||
&mut self,
|
||||
parent: String,
|
||||
result: Result<Vec<EntityNode>, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.pending.remove(&parent);
|
||||
match result {
|
||||
Ok(mut children) => {
|
||||
sort_entries(&mut children);
|
||||
self.children.insert(parent, children);
|
||||
self.push_rows(cx);
|
||||
}
|
||||
Err(_) => {
|
||||
self.children.insert(parent, Vec::new());
|
||||
self.push_rows(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rows(&self, cx: &mut Context<Self>) {
|
||||
let mut rows = Vec::new();
|
||||
rows.push(TreeRow {
|
||||
id: RowId::new(self.root.clone()),
|
||||
label: self.root.clone(),
|
||||
depth: 0,
|
||||
kind: RowKind::Branch,
|
||||
expanded: self.expanded.contains(&self.root),
|
||||
icon: Some("📂".to_string()),
|
||||
});
|
||||
if self.expanded.contains(&self.root) {
|
||||
self.append_children(&self.root, 1, &mut rows);
|
||||
}
|
||||
|
||||
self.tree_view
|
||||
.update(cx, |tree, cx| tree.set_rows(rows, cx));
|
||||
}
|
||||
|
||||
fn append_children(&self, parent: &str, depth: u32, out: &mut Vec<TreeRow>) {
|
||||
let Some(children) = self.children.get(parent) else { return };
|
||||
for entry in children {
|
||||
let kind = match entry.display_type {
|
||||
DisplayType::Folder => RowKind::Branch,
|
||||
_ => RowKind::Leaf,
|
||||
};
|
||||
let icon = match entry.display_type {
|
||||
DisplayType::Folder => "📁",
|
||||
DisplayType::File => "📄",
|
||||
DisplayType::Stream => "📡",
|
||||
};
|
||||
let is_expanded = self.expanded.contains(&entry.id);
|
||||
out.push(TreeRow {
|
||||
id: RowId::new(entry.id.clone()),
|
||||
label: entry.name.clone(),
|
||||
depth,
|
||||
kind,
|
||||
expanded: is_expanded,
|
||||
icon: Some(icon.to_string()),
|
||||
});
|
||||
if is_expanded {
|
||||
self.append_children(&entry.id, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- TreeView events -----
|
||||
|
||||
fn on_tree_event(&mut self, event: &TreeEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
TreeEvent::ChevronToggled(id) => {
|
||||
let path = id.as_str().to_string();
|
||||
if !self.expanded.remove(&path) {
|
||||
self.expanded.insert(path.clone());
|
||||
self.load_children(path, cx);
|
||||
}
|
||||
self.push_rows(cx);
|
||||
}
|
||||
TreeEvent::RowClicked(id) => {
|
||||
let path = id.as_str();
|
||||
if let Some(entry) = self.find_entry(path) {
|
||||
if !matches!(entry.display_type, DisplayType::Folder) {
|
||||
cx.emit(FileExplorerEvent::FileSelected {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Click primario en cualquier lado también cierra el
|
||||
// menú contextual si estaba abierto.
|
||||
if self.menu.is_some() {
|
||||
self.menu = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
TreeEvent::RowDoubleClicked(id) => {
|
||||
let path = id.as_str();
|
||||
if let Some(entry) = self.find_entry(path) {
|
||||
if !matches!(entry.display_type, DisplayType::Folder) {
|
||||
cx.emit(FileExplorerEvent::FileOpened {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
TreeEvent::ContextMenuRequested { id, position } => {
|
||||
self.open_menu(id.as_ref().map(|i| i.as_str().to_string()), *position, cx);
|
||||
}
|
||||
TreeEvent::ActiveChanged(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_entry(&self, id: &str) -> Option<&EntityNode> {
|
||||
for entries in self.children.values() {
|
||||
if let Some(e) = entries.iter().find(|e| e.id == id) {
|
||||
return Some(e);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_folder_path(&self, id: &str) -> bool {
|
||||
self.find_entry(id)
|
||||
.map(|e| matches!(e.display_type, DisplayType::Folder))
|
||||
.unwrap_or_else(|| std::path::Path::new(id).is_dir())
|
||||
}
|
||||
|
||||
/// Resuelve el directorio donde crear un nuevo archivo/carpeta según
|
||||
/// el target del menú: si target es folder → adentro; si target es
|
||||
/// file → su parent; si target es None (fondo) → root.
|
||||
fn parent_for_new(&self, target: &Option<MenuTarget>) -> String {
|
||||
match target {
|
||||
None => self.root.clone(),
|
||||
Some(t) if t.is_folder => t.id.clone(),
|
||||
Some(t) => std::path::Path::new(&t.id)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| self.root.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- menú: open/close -----
|
||||
|
||||
fn open_menu(
|
||||
&mut self,
|
||||
id: Option<String>,
|
||||
position: Point<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let target = id.map(|id| {
|
||||
let is_folder = self.is_folder_path(&id);
|
||||
MenuTarget { id, is_folder }
|
||||
});
|
||||
self.menu = Some(MenuState { target, position });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn close_menu(&mut self, cx: &mut Context<Self>) {
|
||||
if self.menu.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- acciones del menú -----
|
||||
|
||||
fn action_open(&mut self, target: MenuTarget, cx: &mut Context<Self>) {
|
||||
if !target.is_folder {
|
||||
cx.emit(FileExplorerEvent::FileOpened { path: target.id });
|
||||
}
|
||||
self.close_menu(cx);
|
||||
}
|
||||
|
||||
fn action_copy_path(
|
||||
&mut self,
|
||||
target: MenuTarget,
|
||||
_w: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(target.id));
|
||||
self.close_menu(cx);
|
||||
}
|
||||
|
||||
fn action_new_file(
|
||||
&mut self,
|
||||
target: Option<MenuTarget>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let parent = self.parent_for_new(&target);
|
||||
if let Some(name) = next_available_name(&parent, "new_file_", ".txt", false) {
|
||||
let full = std::path::Path::new(&parent).join(&name);
|
||||
if let Err(e) = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&full)
|
||||
{
|
||||
eprintln!("[FileExplorer] new file {:?}: {}", full, e);
|
||||
}
|
||||
}
|
||||
self.refresh_dir(parent, cx);
|
||||
self.close_menu(cx);
|
||||
}
|
||||
|
||||
fn action_new_folder(
|
||||
&mut self,
|
||||
target: Option<MenuTarget>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let parent = self.parent_for_new(&target);
|
||||
if let Some(name) = next_available_name(&parent, "new_folder_", "", true) {
|
||||
let full = std::path::Path::new(&parent).join(&name);
|
||||
if let Err(e) = std::fs::create_dir(&full) {
|
||||
eprintln!("[FileExplorer] new folder {:?}: {}", full, e);
|
||||
}
|
||||
}
|
||||
self.refresh_dir(parent, cx);
|
||||
self.close_menu(cx);
|
||||
}
|
||||
|
||||
fn action_rename(
|
||||
&mut self,
|
||||
target: MenuTarget,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Nombre actual = file_name del path. Si por alguna razón no
|
||||
// tiene file_name (path raíz), usamos el path completo.
|
||||
let original_name = std::path::Path::new(&target.id)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| target.id.clone());
|
||||
|
||||
let initial = original_name.clone();
|
||||
let input = cx.new(|cx| TextInput::new(initial, cx));
|
||||
|
||||
// Subscribirse a los eventos del input para confirmar/cancelar.
|
||||
cx.subscribe(&input, {
|
||||
let target_path = target.id.clone();
|
||||
let original_name = original_name.clone();
|
||||
move |this: &mut FileExplorer, _, ev: &TextInputEvent, cx| match ev {
|
||||
TextInputEvent::Confirmed(new_name) => {
|
||||
this.commit_rename(&target_path, &original_name, new_name.clone(), cx);
|
||||
}
|
||||
TextInputEvent::Cancelled => {
|
||||
this.close_rename(cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Pedir focus para que las próximas teclas vayan al input. Hay
|
||||
// que hacerlo después de que el render lo monte; lo más seguro
|
||||
// es delay un frame con cx.spawn + immediate await.
|
||||
input.update(cx, |i, _| i.request_focus(window));
|
||||
|
||||
self.rename = Some(RenameState {
|
||||
original_name,
|
||||
input,
|
||||
});
|
||||
self.close_menu(cx);
|
||||
}
|
||||
|
||||
fn close_rename(&mut self, cx: &mut Context<Self>) {
|
||||
if self.rename.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_rename(
|
||||
&mut self,
|
||||
target_path: &str,
|
||||
original_name: &str,
|
||||
new_name: String,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let trimmed = new_name.trim();
|
||||
let parent_dir = std::path::Path::new(target_path)
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(self.root.clone()));
|
||||
|
||||
if !trimmed.is_empty() && trimmed != original_name {
|
||||
let from = std::path::PathBuf::from(target_path);
|
||||
let to = parent_dir.join(trimmed);
|
||||
if let Err(e) = std::fs::rename(&from, &to) {
|
||||
eprintln!("[FileExplorer] rename {:?} → {:?}: {}", from, to, e);
|
||||
}
|
||||
}
|
||||
|
||||
let parent_str = parent_dir.to_string_lossy().into_owned();
|
||||
self.refresh_dir(parent_str, cx);
|
||||
self.close_rename(cx);
|
||||
}
|
||||
|
||||
fn action_delete(
|
||||
&mut self,
|
||||
target: MenuTarget,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Prompt nativo del platform — devuelve un Receiver con el índice
|
||||
// del botón clickeado. Esperamos el resultado en un task spawn.
|
||||
let path = target.id.clone();
|
||||
let is_folder = target.is_folder;
|
||||
let name = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.clone());
|
||||
let parent_dir = std::path::Path::new(&path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| self.root.clone());
|
||||
|
||||
let answer = window.prompt(
|
||||
PromptLevel::Warning,
|
||||
&format!("¿Borrar \"{}\"?", name),
|
||||
None,
|
||||
&["Borrar", "Cancelar"],
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Ok(idx) = answer.await else { return };
|
||||
// 0 = Borrar, 1 = Cancelar.
|
||||
if idx != 0 {
|
||||
return;
|
||||
}
|
||||
let res = if is_folder {
|
||||
std::fs::remove_dir_all(&path)
|
||||
} else {
|
||||
std::fs::remove_file(&path)
|
||||
};
|
||||
if let Err(e) = res {
|
||||
eprintln!("[FileExplorer] delete {}: {}", path, e);
|
||||
}
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.refresh_dir(parent_dir, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.close_menu(cx);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers FS ----
|
||||
|
||||
fn sort_entries(entries: &mut Vec<EntityNode>) {
|
||||
entries.sort_by(|a, b| {
|
||||
let a_dir = matches!(a.display_type, DisplayType::Folder);
|
||||
let b_dir = matches!(b.display_type, DisplayType::Folder);
|
||||
b_dir
|
||||
.cmp(&a_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Primer nombre `prefix{n}{suffix}` (n = 1..=999) que no exista en `dir`.
|
||||
/// Para folder no agregamos sufijo. Devuelve None si los 999 nombres ya
|
||||
/// estaban en uso (improbable).
|
||||
fn next_available_name(
|
||||
dir: &str,
|
||||
prefix: &str,
|
||||
suffix: &str,
|
||||
is_folder: bool,
|
||||
) -> Option<String> {
|
||||
for n in 1..=999u32 {
|
||||
let candidate = format!("{}{}{}", prefix, n, suffix);
|
||||
let full = std::path::Path::new(dir).join(&candidate);
|
||||
let exists = if is_folder {
|
||||
full.is_dir()
|
||||
} else {
|
||||
full.exists()
|
||||
};
|
||||
if !exists {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Render
|
||||
// =====================================================================
|
||||
|
||||
const MENU_WIDTH: f32 = 200.0;
|
||||
|
||||
impl Render for FileExplorer {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let pending_count = self.pending.len();
|
||||
|
||||
// Capa 1: header + tree.
|
||||
let header = div()
|
||||
.h(px(28.0))
|
||||
.px(px(8.0))
|
||||
.border_b_1()
|
||||
.border_color(theme.border)
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.gap(px(6.0))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child("📂"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child(SharedString::from(self.root.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.ml_auto()
|
||||
.text_size(px(10.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child(SharedString::from(if pending_count > 0 {
|
||||
format!("⏳ {}", pending_count)
|
||||
} else {
|
||||
String::new()
|
||||
})),
|
||||
);
|
||||
|
||||
let body = div().flex_grow().min_h(px(0.0)).child(self.tree_view.clone());
|
||||
|
||||
// Capa 2 (overlay condicional): menú contextual.
|
||||
let menu_overlay = self.menu.clone().map(|menu| self.render_menu(&theme, menu, cx));
|
||||
// Capa 3 (overlay condicional): modal de rename.
|
||||
let rename_overlay = self.rename.clone().map(|st| self.render_rename(&theme, st));
|
||||
|
||||
let mut root = div()
|
||||
.id("file-explorer-root")
|
||||
.size_full()
|
||||
.relative()
|
||||
.bg(theme.bg_panel.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(header)
|
||||
.child(body);
|
||||
|
||||
if let Some(overlay) = menu_overlay {
|
||||
root = root.child(overlay);
|
||||
}
|
||||
if let Some(overlay) = rename_overlay {
|
||||
root = root.child(overlay);
|
||||
}
|
||||
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
impl FileExplorer {
|
||||
fn render_menu(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
menu: MenuState,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let on_entry = menu.target.is_some();
|
||||
let target = menu.target.clone();
|
||||
let is_folder = target.as_ref().map(|t| t.is_folder).unwrap_or(false);
|
||||
|
||||
let mut items = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.py(px(4.0))
|
||||
.min_w(px(MENU_WIDTH))
|
||||
.bg(theme.bg_panel_alt.clone())
|
||||
.border_1()
|
||||
.border_color(theme.border_strong)
|
||||
.rounded(px(6.0));
|
||||
|
||||
// Open: solo file targets.
|
||||
if on_entry && !is_folder {
|
||||
let t = target.clone().unwrap();
|
||||
items = items.child(
|
||||
menu_item("fe-menu-open", "Abrir", theme).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.action_open(t.clone(), cx);
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
// Copy path.
|
||||
if let Some(t) = target.clone() {
|
||||
let t_clone = t.clone();
|
||||
items = items.child(
|
||||
menu_item("fe-menu-copy", "Copiar ruta", theme).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, w, cx| {
|
||||
this.action_copy_path(t_clone.clone(), w, cx);
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
// Rename — solo cuando hay target.
|
||||
if let Some(t) = target.clone() {
|
||||
items = items.child(
|
||||
menu_item("fe-menu-rename", "Renombrar…", theme).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, w, cx| {
|
||||
this.action_rename(t.clone(), w, cx);
|
||||
},
|
||||
)),
|
||||
);
|
||||
items = items.child(separator(theme));
|
||||
}
|
||||
|
||||
// New file.
|
||||
let new_target = target.clone();
|
||||
items = items.child(
|
||||
menu_item("fe-menu-newfile", "Nuevo archivo", theme).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.action_new_file(new_target.clone(), cx);
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
||||
// New folder.
|
||||
let new_target_folder = target.clone();
|
||||
items = items.child(
|
||||
menu_item("fe-menu-newfolder", "Nueva carpeta", theme).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.action_new_folder(new_target_folder.clone(), cx);
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
||||
// Delete: solo entries existentes.
|
||||
if let Some(t) = target.clone() {
|
||||
items = items.child(separator(theme));
|
||||
items = items.child(
|
||||
menu_item("fe-menu-delete", "Borrar", theme).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, w, cx| {
|
||||
this.action_delete(t.clone(), w, cx);
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
// Wrapper absolute en la posición del click. Las coords del
|
||||
// ContextMenuRequested son window-coords absolutas; las pasamos
|
||||
// directo con .left/.top.
|
||||
div()
|
||||
.absolute()
|
||||
.left(menu.position.x)
|
||||
.top(menu.position.y)
|
||||
.child(items)
|
||||
}
|
||||
}
|
||||
|
||||
impl FileExplorer {
|
||||
fn render_rename(&self, theme: &Theme, state: RenameState) -> impl IntoElement {
|
||||
// Backdrop oscuro semi-transparente que cubre todo el explorer +
|
||||
// caja centrada con el TextInput. Click sobre el backdrop NO
|
||||
// cierra (queremos forzar Enter/Escape para evitar pérdida
|
||||
// accidental de input).
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(0.0))
|
||||
.left(px(0.0))
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(gpui::hsla(0.0, 0.0, 0.0, 0.55))
|
||||
.child(
|
||||
div()
|
||||
.min_w(px(360.0))
|
||||
.p(px(16.0))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(10.0))
|
||||
.bg(theme.bg_panel_alt.clone())
|
||||
.border_1()
|
||||
.border_color(theme.border_strong)
|
||||
.rounded(px(8.0))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(13.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child(SharedString::from(format!(
|
||||
"Renombrar \"{}\"",
|
||||
state.original_name
|
||||
))),
|
||||
)
|
||||
.child(state.input.clone())
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child("Enter = confirmar — Escape = cancelar"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_item(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
theme: &Theme,
|
||||
) -> gpui::Stateful<gpui::Div> {
|
||||
div()
|
||||
.id(id)
|
||||
.px(px(12.0))
|
||||
.py(px(6.0))
|
||||
.text_size(px(12.0))
|
||||
.text_color(theme.fg_text)
|
||||
.hover(|s| s.bg(theme.bg_row_hover))
|
||||
.child(label)
|
||||
}
|
||||
|
||||
fn separator(theme: &Theme) -> gpui::Div {
|
||||
div()
|
||||
.my(px(3.0))
|
||||
.h(px(1.0))
|
||||
.w_full()
|
||||
.bg(theme.border)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "yahweh-image-viewer"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Visor de imágenes. Suscribe al AppBus y renderea con gpui::img(path)."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-bus = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
@@ -0,0 +1,148 @@
|
||||
//! `yahweh_image_viewer` — visor de imágenes.
|
||||
//!
|
||||
//! Suscribe al `AppBus` y, en cada `EntitySelected` cuyo provider sea
|
||||
//! `local_fs` y la extensión sugiera imagen (jpg, png, webp, gif), pasa el
|
||||
//! path a `gpui::img(...)` que se encarga del decode + cache. Para otros
|
||||
//! providers o extensiones desconocidas, muestra un mensaje neutro sin
|
||||
//! intentar render (evita binarios random pasando por el decoder).
|
||||
//!
|
||||
//! Detección por extensión: lista de extensiones soportadas en
|
||||
//! [`is_image_path`]. Para discriminar por mime real (sin importar la
|
||||
//! extensión) habría que invocar `image::guess_format` en un task —
|
||||
//! valdrá la pena cuando carguemos imágenes desde SQLite blobs.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, Render, SharedString, Window, div, img, prelude::*, px,
|
||||
};
|
||||
|
||||
use yahweh_bus::{AppBus, AppEvent};
|
||||
use yahweh_theme::Theme;
|
||||
|
||||
const FS_PROVIDER: &str = "local_fs";
|
||||
|
||||
pub struct ImageViewer {
|
||||
/// Path actualmente mostrado (si lo hay).
|
||||
current_path: Option<String>,
|
||||
/// Mensaje a mostrar cuando no se puede renderear (extensión no
|
||||
/// reconocida, provider sin soporte, etc.).
|
||||
notice: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl ImageViewer {
|
||||
pub fn new(bus: Entity<AppBus>, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
cx.subscribe(&bus, |this: &mut ImageViewer, _, ev, cx| {
|
||||
this.on_app_event(ev, cx);
|
||||
})
|
||||
.detach();
|
||||
Self {
|
||||
current_path: None,
|
||||
notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_app_event(&mut self, event: &AppEvent, cx: &mut Context<Self>) {
|
||||
let (provider, id) = match event {
|
||||
AppEvent::EntitySelected { provider, id, .. }
|
||||
| AppEvent::EntityOpened { provider, id, .. } => (provider, id),
|
||||
};
|
||||
|
||||
if provider != FS_PROVIDER {
|
||||
self.current_path = None;
|
||||
self.notice = Some(
|
||||
format!("provider '{}' no soportado por ImageViewer", provider).into(),
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if !is_image_path(id) {
|
||||
self.current_path = None;
|
||||
self.notice = Some("(no es una imagen reconocible)".into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
self.current_path = Some(id.clone());
|
||||
self.notice = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_image_path(path: &str) -> bool {
|
||||
let ext = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
matches!(
|
||||
ext.as_str(),
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "ico" | "tiff" | "tif"
|
||||
)
|
||||
}
|
||||
|
||||
impl Render for ImageViewer {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
|
||||
let header_text = match (&self.current_path, &self.notice) {
|
||||
(Some(p), _) => format!("[image] {}", p),
|
||||
(None, Some(n)) => n.to_string(),
|
||||
(None, None) => "(ninguna imagen seleccionada)".to_string(),
|
||||
};
|
||||
|
||||
let body: gpui::AnyElement = match (&self.current_path, &self.notice) {
|
||||
(Some(path), _) => {
|
||||
let path_buf = std::path::PathBuf::from(path);
|
||||
div()
|
||||
.flex_grow()
|
||||
.min_h(px(0.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p(px(12.0))
|
||||
.child(img(path_buf).max_w_full().max_h_full())
|
||||
.into_any_element()
|
||||
}
|
||||
(None, Some(n)) => div()
|
||||
.flex_grow()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.child(n.clone())
|
||||
.into_any_element(),
|
||||
(None, None) => div()
|
||||
.flex_grow()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.child("doble click sobre una imagen en el FileExplorer.")
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.bg(theme.bg_panel.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.h(px(28.0))
|
||||
.px(px(10.0))
|
||||
.border_b_1()
|
||||
.border_color(theme.border)
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child(SharedString::from(header_text)),
|
||||
)
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "yahweh-text-viewer"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Visor de texto plano. Suscribe al AppBus y carga contenido async."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-core = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
yahweh-bus = { workspace = true }
|
||||
yahweh-provider-fs = { workspace = true }
|
||||
yahweh-provider-sqlite = { workspace = true }
|
||||
@@ -0,0 +1,285 @@
|
||||
//! `yahweh_text_viewer` — visor de texto plano.
|
||||
//!
|
||||
//! Suscribe al `AppBus` y, en cada `EntitySelected` / `EntityOpened`,
|
||||
//! decide si el `provider` corresponde a uno que sabe leer (por ahora
|
||||
//! `local_fs` y `sqlite_db`); si sí, dispara `cx.spawn` con el provider
|
||||
//! correspondiente para traer el contenido. Mientras carga muestra
|
||||
//! "(cargando…)"; al terminar lo pinta como texto con saltos de línea
|
||||
//! preservados.
|
||||
//!
|
||||
//! Si el contenido no es válido UTF-8 (binario), muestra los primeros
|
||||
//! N bytes en hex — útil para preview no ciego sin pretender ser un
|
||||
//! editor de binarios.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, Render, SharedString, Window, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use yahweh_bus::{AppBus, AppEvent};
|
||||
use yahweh_core::DataProvider;
|
||||
use yahweh_provider_fs::{FileDataProvider, PROVIDER_ID as FS_PROVIDER_ID};
|
||||
use yahweh_provider_sqlite::{PROVIDER_ID as SQL_PROVIDER_ID, SqliteDataProvider};
|
||||
use yahweh_theme::Theme;
|
||||
|
||||
const PREVIEW_HEX_BYTES: usize = 256;
|
||||
const MAX_TEXT_BYTES: usize = 256 * 1024;
|
||||
|
||||
pub struct TextViewer {
|
||||
/// Última entidad mostrada. `None` ⇒ pantalla en estado "vacío".
|
||||
current: Option<CurrentEntity>,
|
||||
/// Contenido renderizado. Si está cargando se muestra el estado en
|
||||
/// `current`.
|
||||
content: Content,
|
||||
/// Generación monotónica — al cambiar `current` la incrementamos para
|
||||
/// descartar resultados de loads previos que vuelvan tarde.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CurrentEntity {
|
||||
provider: String,
|
||||
provider_path: Option<String>,
|
||||
id: String,
|
||||
loading: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Content {
|
||||
Empty,
|
||||
Loading,
|
||||
Text(SharedString),
|
||||
HexPreview(SharedString),
|
||||
Error(SharedString),
|
||||
Unsupported(SharedString),
|
||||
}
|
||||
|
||||
impl TextViewer {
|
||||
pub fn new(bus: Entity<AppBus>, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
|
||||
cx.subscribe(&bus, |this: &mut TextViewer, _, ev, cx| {
|
||||
this.on_app_event(ev, cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
current: None,
|
||||
content: Content::Empty,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_app_event(&mut self, event: &AppEvent, cx: &mut Context<Self>) {
|
||||
let (provider, provider_path, id) = match event {
|
||||
AppEvent::EntitySelected { provider, provider_path, id }
|
||||
| AppEvent::EntityOpened { provider, provider_path, id } => {
|
||||
(provider.clone(), provider_path.clone(), id.clone())
|
||||
}
|
||||
};
|
||||
|
||||
// Comparar con el actual para evitar reload de lo mismo.
|
||||
if let Some(cur) = &self.current {
|
||||
if cur.provider == provider && cur.id == id && cur.provider_path == provider_path {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
let gen = self.generation;
|
||||
self.current = Some(CurrentEntity {
|
||||
provider: provider.clone(),
|
||||
provider_path: provider_path.clone(),
|
||||
id: id.clone(),
|
||||
loading: true,
|
||||
});
|
||||
self.content = Content::Loading;
|
||||
cx.notify();
|
||||
|
||||
// Dispatch por provider.
|
||||
if provider == FS_PROVIDER_ID {
|
||||
self.spawn_load_fs(id, gen, cx);
|
||||
} else if provider == SQL_PROVIDER_ID {
|
||||
self.spawn_load_sqlite(provider_path, id, gen, cx);
|
||||
} else {
|
||||
self.content = Content::Unsupported(
|
||||
format!("provider '{}' no soportado por TextViewer", provider).into(),
|
||||
);
|
||||
if let Some(cur) = &mut self.current {
|
||||
cur.loading = false;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_load_fs(&self, path: String, gen: u64, cx: &mut Context<Self>) {
|
||||
let provider = Arc::new(FileDataProvider);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = provider.get_data(&path).await;
|
||||
let _ = this.update(cx, |this, cx| this.on_loaded(gen, result, cx));
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn spawn_load_sqlite(
|
||||
&self,
|
||||
provider_path: Option<String>,
|
||||
id: String,
|
||||
gen: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let db_path = provider_path.unwrap_or_else(|| "yahweh.db".to_string());
|
||||
cx.spawn(async move |this, cx| {
|
||||
// El SqliteDataProvider abre la DB en su constructor — si
|
||||
// falla, reportamos error y salimos.
|
||||
let provider = match SqliteDataProvider::new(&db_path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.on_loaded(
|
||||
gen,
|
||||
Err(format!("abriendo {}: {}", db_path, e)),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let result = provider.get_data(&id).await;
|
||||
let _ = this.update(cx, |this, cx| this.on_loaded(gen, result, cx));
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn on_loaded(
|
||||
&mut self,
|
||||
gen: u64,
|
||||
result: Result<Vec<u8>, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Si el usuario cambió de selección antes de que volviera el load,
|
||||
// descartamos este resultado.
|
||||
if gen != self.generation {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(cur) = &mut self.current {
|
||||
cur.loading = false;
|
||||
}
|
||||
|
||||
self.content = match result {
|
||||
Ok(bytes) => bytes_to_content(&bytes),
|
||||
Err(e) => Content::Error(format!("error: {}", e).into()),
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_to_content(bytes: &[u8]) -> Content {
|
||||
if bytes.is_empty() {
|
||||
return Content::Text("(vacío)".into());
|
||||
}
|
||||
let truncated = bytes.len() > MAX_TEXT_BYTES;
|
||||
let slice = if truncated { &bytes[..MAX_TEXT_BYTES] } else { bytes };
|
||||
match std::str::from_utf8(slice) {
|
||||
Ok(s) => {
|
||||
let mut out = s.to_string();
|
||||
if truncated {
|
||||
out.push_str("\n…(truncado)…");
|
||||
}
|
||||
Content::Text(out.into())
|
||||
}
|
||||
Err(_) => {
|
||||
// No es UTF-8: mostramos hex preview de los primeros bytes.
|
||||
let n = bytes.len().min(PREVIEW_HEX_BYTES);
|
||||
let mut hex = String::with_capacity(n * 3);
|
||||
for (i, b) in bytes[..n].iter().enumerate() {
|
||||
if i > 0 && i % 16 == 0 {
|
||||
hex.push('\n');
|
||||
}
|
||||
hex.push_str(&format!("{:02x} ", b));
|
||||
}
|
||||
if bytes.len() > n {
|
||||
hex.push_str(&format!("\n…({} bytes más)", bytes.len() - n));
|
||||
}
|
||||
Content::HexPreview(hex.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextViewer {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
|
||||
let header_text = match &self.current {
|
||||
None => "(ningún archivo seleccionado)".to_string(),
|
||||
Some(cur) => {
|
||||
let suffix = if cur.loading { " ⏳" } else { "" };
|
||||
format!("[{}] {}{}", cur.provider, cur.id, suffix)
|
||||
}
|
||||
};
|
||||
|
||||
let body: gpui::AnyElement = match &self.content {
|
||||
Content::Empty => div()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.child("seleccioná un archivo en el FileExplorer o una entry en el DatabaseExplorer.")
|
||||
.into_any_element(),
|
||||
Content::Loading => div()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.child("(cargando…)")
|
||||
.into_any_element(),
|
||||
Content::Text(s) => div()
|
||||
.text_color(theme.fg_text)
|
||||
.text_size(px(12.0))
|
||||
.font_family("monospace")
|
||||
.child(s.clone())
|
||||
.into_any_element(),
|
||||
Content::HexPreview(s) => div()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.font_family("monospace")
|
||||
.child(s.clone())
|
||||
.into_any_element(),
|
||||
Content::Error(s) => div()
|
||||
.text_color(theme.accent_strong)
|
||||
.text_size(px(11.0))
|
||||
.child(s.clone())
|
||||
.into_any_element(),
|
||||
Content::Unsupported(s) => div()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.child(s.clone())
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.bg(theme.bg_panel.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.h(px(28.0))
|
||||
.px(px(10.0))
|
||||
.border_b_1()
|
||||
.border_color(theme.border)
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child(SharedString::from(header_text)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("text-viewer-body")
|
||||
.flex_grow()
|
||||
.min_h(px(0.0))
|
||||
.overflow_scroll()
|
||||
.p(px(12.0))
|
||||
.child(body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "yahweh-shell"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Bootstrap GPUI + LayoutHost de Yahweh."
|
||||
|
||||
[dependencies]
|
||||
yahweh-core = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
yahweh-provider-fs = { workspace = true }
|
||||
yahweh-provider-sqlite = { workspace = true }
|
||||
yahweh-widget-tree = { workspace = true }
|
||||
yahweh-widget-container-core = { workspace = true }
|
||||
yahweh-widget-splitter = { workspace = true }
|
||||
yahweh-widget-tabs = { workspace = true }
|
||||
yahweh-widget-tiled = { workspace = true }
|
||||
yahweh-bus = { workspace = true }
|
||||
yahweh-file-explorer = { workspace = true }
|
||||
yahweh-database-explorer = { workspace = true }
|
||||
yahweh-text-viewer = { workspace = true }
|
||||
yahweh-image-viewer = { workspace = true }
|
||||
gpui = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "yahweh"
|
||||
path = "src/main.rs"
|
||||
@@ -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