tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd crates/apps/file_explorer/src/lib.rs Rust · 786 lines · 25.4 KB History Who touched what raw

chore: monorepo inicial con arje + minga + yahweh absorbidos 53dbdf0f1 · Sergio ·

1//! `yahweh_file_explorer` — explorer de filesystem con menú contextual.
2//!
3//! Composición canónica del patrón "explorer = TreeView + provider":
4//!
5//! ```text
6//!   FileExplorer
7//!     ├── TreeView (widgets/tree, agnóstico)
8//!     └── FsProvider (libs/providers/fs, async + tokio::io)
9//! ```
10//!
11//! Estado:
12//! - `expanded`: set de paths cuyo chevron está abierto.
13//! - `children`: cache de hijos por path parent (cargado lazy via
14//!   `cx.spawn` + provider).
15//! - `pending`: set de loads en vuelo (anti re-trigger).
16//! - `menu`: estado del menú contextual flotante (Fase 4.5).
17//!
18//! Menú contextual (Fase 4.5):
19//! Right-click sobre una fila o el área vacía del árbol abre un menú con
20//! las acciones FS habituales. Las mutaciones se ejecutan sincrónicamente
21//! contra `std::fs` y luego se invalida el cache del directorio afectado
22//! para forzar reload del provider.
23//!
24//! Acciones soportadas:
25//! - **Open** (solo file): emite `FileExplorerEvent::FileOpened` (que el
26//!   forwarder de la Shell traduce a `AppEvent::EntityOpened` al bus).
27//! - **Copy path**: copia el path absoluto al clipboard.
28//! - **New file**: crea un archivo vacío con nombre auto-generado
29//!   (`new_file_NN.txt`) en el directorio elegido — sin necesitar text
30//!   input por ahora; renombrar viene cuando sumemos un TextInput
31//!   widget.
32//! - **New folder**: crea un directorio con nombre auto (`new_folder_NN`).
33//! - **Delete**: confirmación vía `window.prompt` (dialog del platform);
34//!   si el usuario confirma, `remove_file` o `remove_dir_all` según
35//!   corresponda.
36
37use std::collections::{HashMap, HashSet};
38use std::sync::Arc;
39
40use gpui::{
41    ClickEvent, ClipboardItem, Context, Entity, EventEmitter, IntoElement, Pixels, Point,
42    PromptLevel, Render, SharedString, Window, div, prelude::*, px,
43};
44
45use yahweh_core::{DataProvider, DisplayType, EntityNode};
46use yahweh_provider_fs::FileDataProvider;
47use yahweh_theme::Theme;
48use yahweh_widget_text_input::{TextInput, TextInputEvent};
49use yahweh_widget_tree::{RowId, RowKind, TreeEvent, TreeRow, TreeView};
50
51#[derive(Clone, Debug)]
52#[allow(dead_code)]
53pub enum FileExplorerEvent {
54    FileSelected { path: String },
55    FileOpened { path: String },
56    RootChanged { path: String },
57}
58
59#[derive(Clone, Debug)]
60struct MenuState {
61    /// `None` ⇒ menú "fondo" (área vacía del tree). `Some` ⇒ sobre una
62    /// fila concreta.
63    target: Option<MenuTarget>,
64    /// Posición absoluta donde se hizo el right-click. Para el overlay
65    /// usamos coords window-relative (gpui las maneja como px).
66    position: Point<Pixels>,
67}
68
69#[derive(Clone, Debug)]
70struct MenuTarget {
71    id: String,
72    is_folder: bool,
73}
74
75pub struct FileExplorer {
76    tree_view: Entity<TreeView>,
77    provider: Arc<FileDataProvider>,
78
79    root: String,
80    expanded: HashSet<String>,
81    children: HashMap<String, Vec<EntityNode>>,
82    pending: HashSet<String>,
83    menu: Option<MenuState>,
84    /// Modal de rename activo. `None` ⇒ no hay modal.
85    rename: Option<RenameState>,
86}
87
88/// Estado del modal de rename. El TextInput vive como sub-entity para
89/// que pueda recibir focus + key events. El target_path lo lleva la
90/// closure de subscripción (no lo necesitamos en `self` aparte).
91#[derive(Clone)]
92struct RenameState {
93    original_name: String,
94    input: Entity<TextInput>,
95}
96
97impl EventEmitter<FileExplorerEvent> for FileExplorer {}
98
99impl FileExplorer {
100    pub fn new(root: String, cx: &mut Context<Self>) -> Self {
101        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
102
103        let tree_view = cx.new(|cx| TreeView::new("file-explorer-tree", cx));
104        cx.subscribe(&tree_view, |this: &mut FileExplorer, _, ev, cx| {
105            this.on_tree_event(ev, cx);
106        })
107        .detach();
108
109        let mut expanded = HashSet::new();
110        expanded.insert(root.clone());
111
112        let mut me = Self {
113            tree_view,
114            provider: Arc::new(FileDataProvider),
115            root: root.clone(),
116            expanded,
117            children: HashMap::new(),
118            pending: HashSet::new(),
119            menu: None,
120            rename: None,
121        };
122        me.load_children(root, cx);
123        me
124    }
125
126    pub fn root(&self) -> &str {
127        &self.root
128    }
129
130    #[allow(dead_code)]
131    pub fn set_root(&mut self, path: String, cx: &mut Context<Self>) {
132        if path != self.root {
133            self.root = path.clone();
134            self.expanded.insert(path.clone());
135            self.load_children(path.clone(), cx);
136            self.push_rows(cx);
137            cx.emit(FileExplorerEvent::RootChanged { path });
138        }
139    }
140
141    // ----- load + cache -----
142
143    fn load_children(&mut self, parent: String, cx: &mut Context<Self>) {
144        if self.pending.contains(&parent) || self.children.contains_key(&parent) {
145            return;
146        }
147        self.pending.insert(parent.clone());
148
149        let provider = self.provider.clone();
150        let parent_for_task = parent.clone();
151        cx.spawn(async move |this, cx| {
152            let result = provider.list_children(Some(&parent_for_task)).await;
153            let _ = this.update(cx, |this, cx| {
154                this.on_children_loaded(parent_for_task, result, cx);
155            });
156        })
157        .detach();
158    }
159
160    /// Versión "force": invalida el cache antes de re-pedir. Se usa tras
161    /// una mutación FS (new/delete) para que la UI refleje el cambio.
162    fn refresh_dir(&mut self, parent: String, cx: &mut Context<Self>) {
163        self.children.remove(&parent);
164        self.pending.remove(&parent);
165        self.load_children(parent, cx);
166    }
167
168    fn on_children_loaded(
169        &mut self,
170        parent: String,
171        result: Result<Vec<EntityNode>, String>,
172        cx: &mut Context<Self>,
173    ) {
174        self.pending.remove(&parent);
175        match result {
176            Ok(mut children) => {
177                sort_entries(&mut children);
178                self.children.insert(parent, children);
179                self.push_rows(cx);
180            }
181            Err(_) => {
182                self.children.insert(parent, Vec::new());
183                self.push_rows(cx);
184            }
185        }
186    }
187
188    fn push_rows(&self, cx: &mut Context<Self>) {
189        let mut rows = Vec::new();
190        rows.push(TreeRow {
191            id: RowId::new(self.root.clone()),
192            label: self.root.clone(),
193            depth: 0,
194            kind: RowKind::Branch,
195            expanded: self.expanded.contains(&self.root),
196            icon: Some("📂".to_string()),
197        });
198        if self.expanded.contains(&self.root) {
199            self.append_children(&self.root, 1, &mut rows);
200        }
201
202        self.tree_view
203            .update(cx, |tree, cx| tree.set_rows(rows, cx));
204    }
205
206    fn append_children(&self, parent: &str, depth: u32, out: &mut Vec<TreeRow>) {
207        let Some(children) = self.children.get(parent) else { return };
208        for entry in children {
209            let kind = match entry.display_type {
210                DisplayType::Folder => RowKind::Branch,
211                _ => RowKind::Leaf,
212            };
213            let icon = match entry.display_type {
214                DisplayType::Folder => "📁",
215                DisplayType::File => "📄",
216                DisplayType::Stream => "📡",
217            };
218            let is_expanded = self.expanded.contains(&entry.id);
219            out.push(TreeRow {
220                id: RowId::new(entry.id.clone()),
221                label: entry.name.clone(),
222                depth,
223                kind,
224                expanded: is_expanded,
225                icon: Some(icon.to_string()),
226            });
227            if is_expanded {
228                self.append_children(&entry.id, depth + 1, out);
229            }
230        }
231    }
232
233    // ----- TreeView events -----
234
235    fn on_tree_event(&mut self, event: &TreeEvent, cx: &mut Context<Self>) {
236        match event {
237            TreeEvent::ChevronToggled(id) => {
238                let path = id.as_str().to_string();
239                if !self.expanded.remove(&path) {
240                    self.expanded.insert(path.clone());
241                    self.load_children(path, cx);
242                }
243                self.push_rows(cx);
244            }
245            TreeEvent::RowClicked(id) => {
246                let path = id.as_str();
247                if let Some(entry) = self.find_entry(path) {
248                    if !matches!(entry.display_type, DisplayType::Folder) {
249                        cx.emit(FileExplorerEvent::FileSelected {
250                            path: path.to_string(),
251                        });
252                    }
253                }
254                // Click primario en cualquier lado también cierra el
255                // menú contextual si estaba abierto.
256                if self.menu.is_some() {
257                    self.menu = None;
258                    cx.notify();
259                }
260            }
261            TreeEvent::RowDoubleClicked(id) => {
262                let path = id.as_str();
263                if let Some(entry) = self.find_entry(path) {
264                    if !matches!(entry.display_type, DisplayType::Folder) {
265                        cx.emit(FileExplorerEvent::FileOpened {
266                            path: path.to_string(),
267                        });
268                    }
269                }
270            }
271            TreeEvent::ContextMenuRequested { id, position } => {
272                self.open_menu(id.as_ref().map(|i| i.as_str().to_string()), *position, cx);
273            }
274            TreeEvent::ActiveChanged(_) => {}
275        }
276    }
277
278    fn find_entry(&self, id: &str) -> Option<&EntityNode> {
279        for entries in self.children.values() {
280            if let Some(e) = entries.iter().find(|e| e.id == id) {
281                return Some(e);
282            }
283        }
284        None
285    }
286
287    fn is_folder_path(&self, id: &str) -> bool {
288        self.find_entry(id)
289            .map(|e| matches!(e.display_type, DisplayType::Folder))
290            .unwrap_or_else(|| std::path::Path::new(id).is_dir())
291    }
292
293    /// Resuelve el directorio donde crear un nuevo archivo/carpeta según
294    /// el target del menú: si target es folder → adentro; si target es
295    /// file → su parent; si target es None (fondo) → root.
296    fn parent_for_new(&self, target: &Option<MenuTarget>) -> String {
297        match target {
298            None => self.root.clone(),
299            Some(t) if t.is_folder => t.id.clone(),
300            Some(t) => std::path::Path::new(&t.id)
301                .parent()
302                .map(|p| p.to_string_lossy().into_owned())
303                .unwrap_or_else(|| self.root.clone()),
304        }
305    }
306
307    // ----- menú: open/close -----
308
309    fn open_menu(
310        &mut self,
311        id: Option<String>,
312        position: Point<Pixels>,
313        cx: &mut Context<Self>,
314    ) {
315        let target = id.map(|id| {
316            let is_folder = self.is_folder_path(&id);
317            MenuTarget { id, is_folder }
318        });
319        self.menu = Some(MenuState { target, position });
320        cx.notify();
321    }
322
323    fn close_menu(&mut self, cx: &mut Context<Self>) {
324        if self.menu.take().is_some() {
325            cx.notify();
326        }
327    }
328
329    // ----- acciones del menú -----
330
331    fn action_open(&mut self, target: MenuTarget, cx: &mut Context<Self>) {
332        if !target.is_folder {
333            cx.emit(FileExplorerEvent::FileOpened { path: target.id });
334        }
335        self.close_menu(cx);
336    }
337
338    fn action_copy_path(
339        &mut self,
340        target: MenuTarget,
341        _w: &mut Window,
342        cx: &mut Context<Self>,
343    ) {
344        cx.write_to_clipboard(ClipboardItem::new_string(target.id));
345        self.close_menu(cx);
346    }
347
348    fn action_new_file(
349        &mut self,
350        target: Option<MenuTarget>,
351        cx: &mut Context<Self>,
352    ) {
353        let parent = self.parent_for_new(&target);
354        if let Some(name) = next_available_name(&parent, "new_file_", ".txt", false) {
355            let full = std::path::Path::new(&parent).join(&name);
356            if let Err(e) = std::fs::OpenOptions::new()
357                .write(true)
358                .create_new(true)
359                .open(&full)
360            {
361                eprintln!("[FileExplorer] new file {:?}: {}", full, e);
362            }
363        }
364        self.refresh_dir(parent, cx);
365        self.close_menu(cx);
366    }
367
368    fn action_new_folder(
369        &mut self,
370        target: Option<MenuTarget>,
371        cx: &mut Context<Self>,
372    ) {
373        let parent = self.parent_for_new(&target);
374        if let Some(name) = next_available_name(&parent, "new_folder_", "", true) {
375            let full = std::path::Path::new(&parent).join(&name);
376            if let Err(e) = std::fs::create_dir(&full) {
377                eprintln!("[FileExplorer] new folder {:?}: {}", full, e);
378            }
379        }
380        self.refresh_dir(parent, cx);
381        self.close_menu(cx);
382    }
383
384    fn action_rename(
385        &mut self,
386        target: MenuTarget,
387        window: &mut Window,
388        cx: &mut Context<Self>,
389    ) {
390        // Nombre actual = file_name del path. Si por alguna razón no
391        // tiene file_name (path raíz), usamos el path completo.
392        let original_name = std::path::Path::new(&target.id)
393            .file_name()
394            .map(|s| s.to_string_lossy().into_owned())
395            .unwrap_or_else(|| target.id.clone());
396
397        let initial = original_name.clone();
398        let input = cx.new(|cx| TextInput::new(initial, cx));
399
400        // Subscribirse a los eventos del input para confirmar/cancelar.
401        cx.subscribe(&input, {
402            let target_path = target.id.clone();
403            let original_name = original_name.clone();
404            move |this: &mut FileExplorer, _, ev: &TextInputEvent, cx| match ev {
405                TextInputEvent::Confirmed(new_name) => {
406                    this.commit_rename(&target_path, &original_name, new_name.clone(), cx);
407                }
408                TextInputEvent::Cancelled => {
409                    this.close_rename(cx);
410                }
411            }
412        })
413        .detach();
414
415        // Pedir focus para que las próximas teclas vayan al input. Hay
416        // que hacerlo después de que el render lo monte; lo más seguro
417        // es delay un frame con cx.spawn + immediate await.
418        input.update(cx, |i, _| i.request_focus(window));
419
420        self.rename = Some(RenameState {
421            original_name,
422            input,
423        });
424        self.close_menu(cx);
425    }
426
427    fn close_rename(&mut self, cx: &mut Context<Self>) {
428        if self.rename.take().is_some() {
429            cx.notify();
430        }
431    }
432
433    fn commit_rename(
434        &mut self,
435        target_path: &str,
436        original_name: &str,
437        new_name: String,
438        cx: &mut Context<Self>,
439    ) {
440        let trimmed = new_name.trim();
441        let parent_dir = std::path::Path::new(target_path)
442            .parent()
443            .map(|p| p.to_path_buf())
444            .unwrap_or_else(|| std::path::PathBuf::from(self.root.clone()));
445
446        if !trimmed.is_empty() && trimmed != original_name {
447            let from = std::path::PathBuf::from(target_path);
448            let to = parent_dir.join(trimmed);
449            if let Err(e) = std::fs::rename(&from, &to) {
450                eprintln!("[FileExplorer] rename {:?} → {:?}: {}", from, to, e);
451            }
452        }
453
454        let parent_str = parent_dir.to_string_lossy().into_owned();
455        self.refresh_dir(parent_str, cx);
456        self.close_rename(cx);
457    }
458
459    fn action_delete(
460        &mut self,
461        target: MenuTarget,
462        window: &mut Window,
463        cx: &mut Context<Self>,
464    ) {
465        // Prompt nativo del platform — devuelve un Receiver con el índice
466        // del botón clickeado. Esperamos el resultado en un task spawn.
467        let path = target.id.clone();
468        let is_folder = target.is_folder;
469        let name = std::path::Path::new(&path)
470            .file_name()
471            .map(|s| s.to_string_lossy().into_owned())
472            .unwrap_or_else(|| path.clone());
473        let parent_dir = std::path::Path::new(&path)
474            .parent()
475            .map(|p| p.to_string_lossy().into_owned())
476            .unwrap_or_else(|| self.root.clone());
477
478        let answer = window.prompt(
479            PromptLevel::Warning,
480            &format!("¿Borrar \"{}\"?", name),
481            None,
482            &["Borrar", "Cancelar"],
483            cx,
484        );
485
486        cx.spawn(async move |this, cx| {
487            let Ok(idx) = answer.await else { return };
488            // 0 = Borrar, 1 = Cancelar.
489            if idx != 0 {
490                return;
491            }
492            let res = if is_folder {
493                std::fs::remove_dir_all(&path)
494            } else {
495                std::fs::remove_file(&path)
496            };
497            if let Err(e) = res {
498                eprintln!("[FileExplorer] delete {}: {}", path, e);
499            }
500            let _ = this.update(cx, |this, cx| {
501                this.refresh_dir(parent_dir, cx);
502            });
503        })
504        .detach();
505
506        self.close_menu(cx);
507    }
508}
509
510// ---- helpers FS ----
511
512fn sort_entries(entries: &mut Vec<EntityNode>) {
513    entries.sort_by(|a, b| {
514        let a_dir = matches!(a.display_type, DisplayType::Folder);
515        let b_dir = matches!(b.display_type, DisplayType::Folder);
516        b_dir
517            .cmp(&a_dir)
518            .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
519    });
520}
521
522/// Primer nombre `prefix{n}{suffix}` (n = 1..=999) que no exista en `dir`.
523/// Para folder no agregamos sufijo. Devuelve None si los 999 nombres ya
524/// estaban en uso (improbable).
525fn next_available_name(
526    dir: &str,
527    prefix: &str,
528    suffix: &str,
529    is_folder: bool,
530) -> Option<String> {
531    for n in 1..=999u32 {
532        let candidate = format!("{}{}{}", prefix, n, suffix);
533        let full = std::path::Path::new(dir).join(&candidate);
534        let exists = if is_folder {
535            full.is_dir()
536        } else {
537            full.exists()
538        };
539        if !exists {
540            return Some(candidate);
541        }
542    }
543    None
544}
545
546// =====================================================================
547// Render
548// =====================================================================
549
550const MENU_WIDTH: f32 = 200.0;
551
552impl Render for FileExplorer {
553    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
554        let theme = Theme::global(cx).clone();
555        let pending_count = self.pending.len();
556
557        // Capa 1: header + tree.
558        let header = div()
559            .h(px(28.0))
560            .px(px(8.0))
561            .border_b_1()
562            .border_color(theme.border)
563            .flex()
564            .flex_row()
565            .items_center()
566            .gap(px(6.0))
567            .child(
568                div()
569                    .text_size(px(11.0))
570                    .text_color(theme.fg_muted)
571                    .child("📂"),
572            )
573            .child(
574                div()
575                    .text_size(px(11.0))
576                    .text_color(theme.fg_text)
577                    .child(SharedString::from(self.root.clone())),
578            )
579            .child(
580                div()
581                    .ml_auto()
582                    .text_size(px(10.0))
583                    .text_color(theme.fg_muted)
584                    .child(SharedString::from(if pending_count > 0 {
585                        format!("⏳ {}", pending_count)
586                    } else {
587                        String::new()
588                    })),
589            );
590
591        let body = div().flex_grow().min_h(px(0.0)).child(self.tree_view.clone());
592
593        // Capa 2 (overlay condicional): menú contextual.
594        let menu_overlay = self.menu.clone().map(|menu| self.render_menu(&theme, menu, cx));
595        // Capa 3 (overlay condicional): modal de rename.
596        let rename_overlay = self.rename.clone().map(|st| self.render_rename(&theme, st));
597
598        let mut root = div()
599            .id("file-explorer-root")
600            .size_full()
601            .relative()
602            .bg(theme.bg_panel.clone())
603            .flex()
604            .flex_col()
605            .child(header)
606            .child(body);
607
608        if let Some(overlay) = menu_overlay {
609            root = root.child(overlay);
610        }
611        if let Some(overlay) = rename_overlay {
612            root = root.child(overlay);
613        }
614
615        root
616    }
617}
618
619impl FileExplorer {
620    fn render_menu(
621        &self,
622        theme: &Theme,
623        menu: MenuState,
624        cx: &mut Context<Self>,
625    ) -> impl IntoElement {
626        let on_entry = menu.target.is_some();
627        let target = menu.target.clone();
628        let is_folder = target.as_ref().map(|t| t.is_folder).unwrap_or(false);
629
630        let mut items = div()
631            .flex()
632            .flex_col()
633            .py(px(4.0))
634            .min_w(px(MENU_WIDTH))
635            .bg(theme.bg_panel_alt.clone())
636            .border_1()
637            .border_color(theme.border_strong)
638            .rounded(px(6.0));
639
640        // Open: solo file targets.
641        if on_entry && !is_folder {
642            let t = target.clone().unwrap();
643            items = items.child(
644                menu_item("fe-menu-open", "Abrir", theme).on_click(cx.listener(
645                    move |this, _: &ClickEvent, _, cx| {
646                        this.action_open(t.clone(), cx);
647                    },
648                )),
649            );
650        }
651
652        // Copy path.
653        if let Some(t) = target.clone() {
654            let t_clone = t.clone();
655            items = items.child(
656                menu_item("fe-menu-copy", "Copiar ruta", theme).on_click(cx.listener(
657                    move |this, _: &ClickEvent, w, cx| {
658                        this.action_copy_path(t_clone.clone(), w, cx);
659                    },
660                )),
661            );
662        }
663
664        // Rename — solo cuando hay target.
665        if let Some(t) = target.clone() {
666            items = items.child(
667                menu_item("fe-menu-rename", "Renombrar…", theme).on_click(cx.listener(
668                    move |this, _: &ClickEvent, w, cx| {
669                        this.action_rename(t.clone(), w, cx);
670                    },
671                )),
672            );
673            items = items.child(separator(theme));
674        }
675
676        // New file.
677        let new_target = target.clone();
678        items = items.child(
679            menu_item("fe-menu-newfile", "Nuevo archivo", theme).on_click(cx.listener(
680                move |this, _: &ClickEvent, _, cx| {
681                    this.action_new_file(new_target.clone(), cx);
682                },
683            )),
684        );
685
686        // New folder.
687        let new_target_folder = target.clone();
688        items = items.child(
689            menu_item("fe-menu-newfolder", "Nueva carpeta", theme).on_click(cx.listener(
690                move |this, _: &ClickEvent, _, cx| {
691                    this.action_new_folder(new_target_folder.clone(), cx);
692                },
693            )),
694        );
695
696        // Delete: solo entries existentes.
697        if let Some(t) = target.clone() {
698            items = items.child(separator(theme));
699            items = items.child(
700                menu_item("fe-menu-delete", "Borrar", theme).on_click(cx.listener(
701                    move |this, _: &ClickEvent, w, cx| {
702                        this.action_delete(t.clone(), w, cx);
703                    },
704                )),
705            );
706        }
707
708        // Wrapper absolute en la posición del click. Las coords del
709        // ContextMenuRequested son window-coords absolutas; las pasamos
710        // directo con .left/.top.
711        div()
712            .absolute()
713            .left(menu.position.x)
714            .top(menu.position.y)
715            .child(items)
716    }
717}
718
719impl FileExplorer {
720    fn render_rename(&self, theme: &Theme, state: RenameState) -> impl IntoElement {
721        // Backdrop oscuro semi-transparente que cubre todo el explorer +
722        // caja centrada con el TextInput. Click sobre el backdrop NO
723        // cierra (queremos forzar Enter/Escape para evitar pérdida
724        // accidental de input).
725        div()
726            .absolute()
727            .top(px(0.0))
728            .left(px(0.0))
729            .size_full()
730            .flex()
731            .items_center()
732            .justify_center()
733            .bg(gpui::hsla(0.0, 0.0, 0.0, 0.55))
734            .child(
735                div()
736                    .min_w(px(360.0))
737                    .p(px(16.0))
738                    .flex()
739                    .flex_col()
740                    .gap(px(10.0))
741                    .bg(theme.bg_panel_alt.clone())
742                    .border_1()
743                    .border_color(theme.border_strong)
744                    .rounded(px(8.0))
745                    .child(
746                        div()
747                            .text_size(px(13.0))
748                            .text_color(theme.fg_text)
749                            .child(SharedString::from(format!(
750                                "Renombrar \"{}\"",
751                                state.original_name
752                            ))),
753                    )
754                    .child(state.input.clone())
755                    .child(
756                        div()
757                            .text_size(px(10.0))
758                            .text_color(theme.fg_muted)
759                            .child("Enter = confirmar — Escape = cancelar"),
760                    ),
761            )
762    }
763}
764
765fn menu_item(
766    id: &'static str,
767    label: &'static str,
768    theme: &Theme,
769) -> gpui::Stateful<gpui::Div> {
770    div()
771        .id(id)
772        .px(px(12.0))
773        .py(px(6.0))
774        .text_size(px(12.0))
775        .text_color(theme.fg_text)
776        .hover(|s| s.bg(theme.bg_row_hover))
777        .child(label)
778}
779
780fn separator(theme: &Theme) -> gpui::Div {
781    div()
782        .my(px(3.0))
783        .h(px(1.0))
784        .w_full()
785        .bg(theme.border)
786}