⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd
crates/apps/file_explorer/src/lib.rs
History
Who touched what
raw
chore: monorepo inicial con arje + minga + yahweh absorbidos
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}