Skip to main content

llimphi_module_fif/
lib.rs

1//! `llimphi-module-fif` — find-in-files reutilizable (estilo JetBrains).
2//!
3//! Módulo Llimphi con dos vistas independientes:
4//!
5//! - [`view_dialog`] — popup compacto (header + input) que el host pinta
6//!   como overlay modal centrado. Sólo visible cuando
7//!   [`FifState::dialog_open`] es `true`.
8//! - [`view_results_bar`] — barra inferior persistente con la lista de
9//!   matches. El host la pinta como tool window al pie (estilo JetBrains
10//!   "Find" tool window). Sobrevive al cierre del dialog: el user puede
11//!   Esc-cerrar el popup y seguir clickeando los resultados.
12//!
13//! El flujo típico es: `Ctrl+Shift+F` abre el dialog → tipear → Enter
14//! ejecuta `search` → resultados aparecen en la barra inferior → Esc
15//! cierra el popup pero la barra queda → click en una fila abre el
16//! archivo. Re-disparar `Ctrl+Shift+F` reabre el popup conservando los
17//! últimos resultados.
18//!
19//! ## Cómo lo enchufa una app
20//!
21//! ```ignore
22//! struct AppModel {
23//!     all_files: Vec<PathBuf>,
24//!     fif: Option<FifState>,
25//!     // …
26//! }
27//!
28//! enum AppMsg { Fif(llimphi_module_fif::FifMsg), … }
29//!
30//! // En update(model, msg):
31//! AppMsg::Fif(fm) => {
32//!     // Lazy-init en Open:
33//!     if matches!(fm, FifMsg::Open) && model.fif.is_none() {
34//!         model.fif = Some(FifState::new());
35//!     } else if matches!(fm, FifMsg::Open) {
36//!         model.fif.as_mut().unwrap().dialog_open = true;
37//!     }
38//!     let action = match model.fif.as_mut() {
39//!         Some(s) => llimphi_module_fif::apply(s, fm, &model.all_files),
40//!         None => FifAction::None,
41//!     };
42//!     match action {
43//!         FifAction::None => {}
44//!         FifAction::CloseDialog => {
45//!             if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
46//!         }
47//!         FifAction::CloseAll => model.fif = None,
48//!         FifAction::Searched { .. } => { /* actualizar status bar */ }
49//!         FifAction::OpenAt { path, line, col } => {
50//!             if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
51//!             open_path_in_app(path, line, col);
52//!         }
53//!     }
54//! }
55//!
56//! // En on_key(model, event): solo rutea cuando el dialog está visible.
57//! if let Some(state) = model.fif.as_ref() {
58//!     if let Some(fm) = llimphi_module_fif::on_key(state, event) {
59//!         return Some(AppMsg::Fif(fm));
60//!     }
61//! }
62//! if llimphi_module_fif::open_shortcut(event) {
63//!     return Some(AppMsg::Fif(FifMsg::Open));
64//! }
65//!
66//! // En view(model):
67//! //   - dialog como overlay arriba del editor:
68//! if let Some(s) = model.fif.as_ref().filter(|s| s.dialog_open) {
69//!     overlay_children.push(view_dialog(s, &palette, AppMsg::Fif));
70//! }
71//! //   - barra de resultados como panel inferior persistente:
72//! if let Some(s) = model.fif.as_ref().filter(|s| !s.results.is_empty()) {
73//!     bottom_panels.push(view_results_bar(
74//!         s, &model.all_files, &model.root, &palette, AppMsg::Fif,
75//!     ));
76//! }
77//! ```
78//!
79//! ## Por qué Action en lugar de un trait `FifHost`
80//!
81//! El módulo no toma `&mut Host` porque acoplar el módulo a un trait
82//! arrastra problemas de ownership/lifetimes en el loop tipo Elm que usa
83//! Llimphi (Model se mueve por value en update). Devolver una [`FifAction`]
84//! deja al host libre de aplicar el efecto donde y como quiera, y mantiene
85//! al módulo libre de cualquier conocimiento sobre el host.
86
87#![forbid(unsafe_code)]
88
89use std::path::{Path, PathBuf};
90use std::time::Duration;
91
92use llimphi_ui::llimphi_layout::taffy::{
93    prelude::{length, percent, FlexDirection, Size, Style},
94    AlignItems, JustifyContent, Rect,
95};
96use llimphi_ui::llimphi_raster::peniko::Color;
97use llimphi_ui::llimphi_text::Alignment;
98use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
99use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
100
101/// Capabilities que este módulo aporta al host. Convención del protocolo
102/// Brahman Card aplicada a módulos compile-time: el host (cuando construye
103/// su [`card_core::Card`]) puede agregar esto a `provides` para anunciar
104/// — vía broker — que su instancia ofrece find-in-files al ecosistema.
105pub const CAPABILITIES: &[&str] = &["editor.find-in-files"];
106
107/// Caps razonables para que un workspace grande no funda el UI.
108pub const MAX_RESULTS: usize = 1000;
109pub const MAX_FILE_SIZE: u64 = 2_000_000;
110pub const SNIPPET_MAX_CHARS: usize = 160;
111pub const MIN_QUERY_LEN: usize = 2;
112
113const DIALOG_W: f32 = 560.0;
114const DIALOG_H: f32 = 116.0;
115const BAR_H: f32 = 220.0;
116const ROW_H: f32 = 20.0;
117const MAX_VISIBLE: usize = 9;
118
119/// Qué input tiene el foco dentro del dialog. `Tab` alterna.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum FifFocus {
122    Search,
123    Replace,
124}
125
126/// Un match individual.
127#[derive(Debug, Clone)]
128pub struct FifMatch {
129    /// Índice dentro del slice de paths que el host pasa a [`apply`] y
130    /// las vistas. Convención: el host no debe reordenar/mutar el slice
131    /// entre frames mientras el módulo esté abierto.
132    pub file_idx: usize,
133    /// 0-based.
134    pub line: usize,
135    /// 0-based, en chars (no bytes).
136    pub col: usize,
137    /// Línea matcheada trimmed-left y truncada a [`SNIPPET_MAX_CHARS`].
138    pub snippet: String,
139}
140
141/// Estado interno del módulo.
142pub struct FifState {
143    pub input: TextInputState,
144    /// Texto de reemplazo. Si vacío, `ReplaceAll` borra los matches.
145    pub replace: TextInputState,
146    pub focus: FifFocus,
147    pub results: Vec<FifMatch>,
148    pub selected: usize,
149    /// Última query realmente ejecutada (puede diferir del input si el
150    /// user siguió tipeando sin re-Enter).
151    pub last_query: String,
152    /// `true` cuando el popup modal está visible. La barra de resultados
153    /// se pinta independientemente de esto: sobrevive al cierre del popup.
154    pub dialog_open: bool,
155}
156
157impl Default for FifState {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163impl FifState {
164    pub fn new() -> Self {
165        Self {
166            input: TextInputState::new(),
167            replace: TextInputState::new(),
168            focus: FifFocus::Search,
169            results: Vec::new(),
170            selected: 0,
171            last_query: String::new(),
172            dialog_open: true,
173        }
174    }
175}
176
177/// Vocabulario interno. El host lo wrapea en su propio Msg.
178#[derive(Clone)]
179pub enum FifMsg {
180    /// El host detectó el atajo de apertura (o un comando). Lazy-init del
181    /// state lo hace el host; `apply` sólo marca `dialog_open = true`.
182    Open,
183    /// El user pidió cerrar el popup (Esc). Los resultados quedan en la
184    /// barra inferior.
185    CloseDialog,
186    /// Cerrar todo: el host debería tirar el `FifState` completo.
187    CloseAll,
188    /// Tecla rumbo al input.
189    KeyInput(KeyEvent),
190    /// Navegación dentro de la lista de resultados.
191    Nav(i32),
192    /// Enter: la primera vez ejecuta search; subsiguientes abren el
193    /// match seleccionado.
194    Submit,
195    /// Click en una fila de la barra inferior: selecciona y abre.
196    ActivateAt(usize),
197    /// Alterna el foco entre los inputs search ↔ replace (Tab).
198    ToggleFocus,
199    /// Reemplaza el texto matcheado por `replace.text()` en todos los
200    /// matches actuales. Idempotente: re-leer el archivo, sustituir
201    /// case-insensitive por la query, escribir. Vacía `results` para
202    /// forzar nueva búsqueda si el user quiere ver el estado posterior.
203    ReplaceAll,
204}
205
206/// Efecto solicitado al host. El módulo nunca toca el FS ni el resto del
207/// modelo de la app — devuelve el deseo, el host elige cómo lo aplica.
208#[derive(Debug, Clone)]
209pub enum FifAction {
210    None,
211    /// El host debería marcar `state.dialog_open = false` y dejar el
212    /// resto del state intacto (resultados visibles en la barra).
213    CloseDialog,
214    /// El host debería remover el state del modelo entero.
215    CloseAll,
216    /// Tras un Submit que ejecutó search.
217    Searched { matches: usize, elapsed: Duration, query: String },
218    /// El host debería abrir `path` y posicionar el caret en `(line, col)`.
219    /// El módulo NO se cierra automáticamente: el host decide si ocultar
220    /// el dialog tras abrir el match.
221    OpenAt { path: PathBuf, line: usize, col: usize },
222    /// Tras `ReplaceAll`: cuántos archivos tocados, cuántos matches
223    /// sustituidos, cuántos fallaron. El host debería refrescar buffers
224    /// abiertos (recargar de disco si no-dirty) y mostrar status.
225    Replaced {
226        files_changed: usize,
227        replacements: usize,
228        failures: usize,
229        query: String,
230        replacement: String,
231    },
232}
233
234/// Aplica un mensaje al estado y retorna el efecto que el host debe ejecutar.
235///
236/// `paths` es la lista canónica de archivos sobre la que buscar. El host
237/// la pasa por referencia; cuando Submit dispara una búsqueda, este
238/// vector se itera y se leen los archivos (skip binarios y >MAX_FILE_SIZE).
239pub fn apply(state: &mut FifState, msg: FifMsg, paths: &[PathBuf]) -> FifAction {
240    match msg {
241        FifMsg::Open => {
242            state.dialog_open = true;
243            FifAction::None
244        }
245        FifMsg::CloseDialog => FifAction::CloseDialog,
246        FifMsg::CloseAll => FifAction::CloseAll,
247        FifMsg::KeyInput(ev) => {
248            let _ = match state.focus {
249                FifFocus::Search => state.input.apply_key(&ev),
250                FifFocus::Replace => state.replace.apply_key(&ev),
251            };
252            FifAction::None
253        }
254        FifMsg::ToggleFocus => {
255            state.focus = match state.focus {
256                FifFocus::Search => FifFocus::Replace,
257                FifFocus::Replace => FifFocus::Search,
258            };
259            FifAction::None
260        }
261        FifMsg::ReplaceAll => {
262            let query = state.last_query.clone();
263            if query.is_empty() || state.results.is_empty() {
264                return FifAction::None;
265            }
266            let replacement = state.replace.text();
267            let (files_changed, replacements, failures) =
268                replace_all(paths, &state.results, &query, &replacement);
269            // Invalidamos resultados: las posiciones (line, col) ya no
270            // necesariamente apuntan al mismo texto. El user puede re-Enter.
271            state.results.clear();
272            state.selected = 0;
273            FifAction::Replaced {
274                files_changed,
275                replacements,
276                failures,
277                query,
278                replacement,
279            }
280        }
281        FifMsg::Nav(d) => {
282            let n = state.results.len() as i32;
283            if n > 0 {
284                state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
285            }
286            FifAction::None
287        }
288        FifMsg::Submit => {
289            let query = state.input.text();
290            let needs_search = query != state.last_query || state.results.is_empty();
291            if needs_search {
292                if query.len() < MIN_QUERY_LEN {
293                    return FifAction::None;
294                }
295                let started = std::time::Instant::now();
296                let results = search(paths, &query);
297                let elapsed = started.elapsed();
298                let n = results.len();
299                state.results = results;
300                state.selected = 0;
301                state.last_query = query.clone();
302                FifAction::Searched { matches: n, elapsed, query }
303            } else {
304                let Some(fm) = state.results.get(state.selected).cloned() else {
305                    return FifAction::None;
306                };
307                let Some(path) = paths.get(fm.file_idx).cloned() else {
308                    return FifAction::None;
309                };
310                FifAction::OpenAt { path, line: fm.line, col: fm.col }
311            }
312        }
313        FifMsg::ActivateAt(idx) => {
314            if idx >= state.results.len() {
315                return FifAction::None;
316            }
317            state.selected = idx;
318            let fm = state.results[idx].clone();
319            let Some(path) = paths.get(fm.file_idx).cloned() else {
320                return FifAction::None;
321            };
322            FifAction::OpenAt { path, line: fm.line, col: fm.col }
323        }
324    }
325}
326
327/// Routing de teclas cuando el dialog está abierto. Si el popup está
328/// cerrado, devuelve `None` y el host puede seguir routeando al editor.
329pub fn on_key(state: &FifState, event: &KeyEvent) -> Option<FifMsg> {
330    if !state.dialog_open {
331        return None;
332    }
333    if event.state != KeyState::Pressed {
334        return None;
335    }
336    Some(match &event.key {
337        Key::Named(NamedKey::Escape) => FifMsg::CloseDialog,
338        Key::Named(NamedKey::Enter) => FifMsg::Submit,
339        Key::Named(NamedKey::Tab) => FifMsg::ToggleFocus,
340        Key::Named(NamedKey::ArrowDown) => FifMsg::Nav(1),
341        Key::Named(NamedKey::ArrowUp) => FifMsg::Nav(-1),
342        _ => FifMsg::KeyInput(event.clone()),
343    })
344}
345
346/// Chequea si el evento es el atajo recomendado: **Ctrl+Shift+F**. El
347/// host puede ignorar esto y definir su propio binding.
348pub fn open_shortcut(event: &KeyEvent) -> bool {
349    event.state == KeyState::Pressed
350        && event.modifiers.ctrl
351        && event.modifiers.shift
352        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f"))
353}
354
355/// Paleta visual. Construible desde un [`llimphi_theme::Theme`].
356#[derive(Debug, Clone)]
357pub struct FifPalette {
358    pub bg_panel: Color,
359    pub bg_header: Color,
360    pub bg_selected: Color,
361    pub fg_text: Color,
362    pub fg_muted: Color,
363    pub border: Color,
364    /// Theme cacheado para reusar en `TextInputPalette::from_theme`.
365    theme: llimphi_theme::Theme,
366}
367
368impl FifPalette {
369    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
370        Self {
371            bg_panel: t.bg_panel,
372            bg_header: t.bg_panel_alt,
373            bg_selected: t.bg_selected,
374            fg_text: t.fg_text,
375            fg_muted: t.fg_muted,
376            border: t.border,
377            theme: t.clone(),
378        }
379    }
380}
381
382/// Popup modal compacto: header + input. Sin lista de resultados — esa
383/// vive en [`view_results_bar`]. El host lo pinta como overlay centrado.
384///
385/// El `View` devuelto tiene tamaño fijo ([`DIALOG_W`] × [`DIALOG_H`]). Si
386/// el host quiere centrarlo, debe envolverlo en un container con
387/// `JustifyContent::Center`/`AlignItems::Center` o usar el slot de overlay.
388pub fn view_dialog<HostMsg, F>(
389    state: &FifState,
390    palette: &FifPalette,
391    to_host: F,
392) -> View<HostMsg>
393where
394    HostMsg: Clone + 'static,
395    F: Fn(FifMsg) -> HostMsg + Copy + 'static,
396{
397    let dirty_query = state.input.text() != state.last_query;
398    let header = if state.last_query.is_empty() {
399        "find in files · Enter busca · Esc cierra".to_string()
400    } else if state.results.is_empty() {
401        format!("«{}» · sin matches · Esc cierra", state.last_query)
402    } else {
403        let staleness = if dirty_query { " · Enter re-busca" } else { "" };
404        format!(
405            "«{}» · {} matches · ↓↑ navega · Enter abre{staleness} · Esc cierra",
406            state.last_query,
407            state.results.len(),
408        )
409    };
410
411    let header_view = View::new(Style {
412        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
413        padding: Rect {
414            left: length(10.0_f32),
415            right: length(10.0_f32),
416            top: length(0.0_f32),
417            bottom: length(0.0_f32),
418        },
419        align_items: Some(AlignItems::Center),
420        flex_shrink: 0.0,
421        ..Default::default()
422    })
423    .fill(palette.bg_header)
424    .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
425
426    let tp = TextInputPalette::from_theme(&palette.theme);
427    let search_focus = state.focus == FifFocus::Search;
428    let search_view = labelled_input(
429        "buscar",
430        &state.input,
431        "buscar en archivos…",
432        search_focus,
433        palette,
434        &tp,
435        to_host(FifMsg::Open),
436    );
437    let replace_view = labelled_input(
438        "reemplazar",
439        &state.replace,
440        "(vacío para borrar)",
441        !search_focus,
442        palette,
443        &tp,
444        to_host(FifMsg::Open),
445    );
446
447    let replace_btn = View::new(Style {
448        size: Size { width: length(118.0_f32), height: length(20.0_f32) },
449        padding: Rect {
450            left: length(6.0_f32),
451            right: length(6.0_f32),
452            top: length(0.0_f32),
453            bottom: length(0.0_f32),
454        },
455        align_items: Some(AlignItems::Center),
456        flex_shrink: 0.0,
457        ..Default::default()
458    })
459    .fill(palette.bg_header)
460    .radius(3.0)
461    .text_aligned(
462        "reemplazar todo".to_string(),
463        10.0,
464        palette.fg_muted,
465        Alignment::Center,
466    )
467    .on_click(to_host(FifMsg::ReplaceAll));
468
469    let hint = View::new(Style {
470        flex_grow: 1.0,
471        size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
472        padding: Rect {
473            left: length(8.0_f32),
474            right: length(8.0_f32),
475            top: length(0.0_f32),
476            bottom: length(0.0_f32),
477        },
478        align_items: Some(AlignItems::Center),
479        ..Default::default()
480    })
481    .text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start);
482
483    let actions = View::new(Style {
484        flex_direction: FlexDirection::Row,
485        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
486        padding: Rect {
487            left: length(8.0_f32),
488            right: length(8.0_f32),
489            top: length(0.0_f32),
490            bottom: length(0.0_f32),
491        },
492        align_items: Some(AlignItems::Center),
493        flex_shrink: 0.0,
494        ..Default::default()
495    })
496    .fill(palette.bg_panel)
497    .children(vec![hint, replace_btn]);
498
499    // Wrapper exterior: tamaño fijo del dialog + borde sutil.
500    let dialog = View::new(Style {
501        flex_direction: FlexDirection::Column,
502        size: Size { width: length(DIALOG_W), height: length(DIALOG_H) },
503        flex_shrink: 0.0,
504        ..Default::default()
505    })
506    .fill(palette.bg_panel)
507    .radius(6.0)
508    .children(vec![header_view, search_view, replace_view, actions]);
509
510    // Container que centra el dialog horizontalmente — el host pone esto
511    // como overlay arriba del editor; un click en zona vacía no hace nada
512    // (no cerramos por click-outside, sería sorpresivo si el user está
513    // ojeando resultados en la barra).
514    View::new(Style {
515        flex_direction: FlexDirection::Row,
516        size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) },
517        padding: Rect {
518            left: length(0.0_f32),
519            right: length(0.0_f32),
520            top: length(12.0_f32),
521            bottom: length(4.0_f32),
522        },
523        justify_content: Some(JustifyContent::Center),
524        align_items: Some(AlignItems::Start),
525        flex_shrink: 0.0,
526        ..Default::default()
527    })
528    .children(vec![dialog])
529}
530
531/// Barra inferior persistente con los matches. Filas clickeables (click
532/// → [`FifMsg::ActivateAt`]). El host la pinta como tool window al pie
533/// del editor, hermana del terminal/output (estilo JetBrains).
534///
535/// Si no hay resultados, devuelve una barra mínima con un mensaje — el
536/// host puede usar `state.results.is_empty()` para no renderizarla.
537pub fn view_results_bar<HostMsg, F>(
538    state: &FifState,
539    paths: &[PathBuf],
540    root: &Path,
541    palette: &FifPalette,
542    to_host: F,
543) -> View<HostMsg>
544where
545    HostMsg: Clone + 'static,
546    F: Fn(FifMsg) -> HostMsg + Copy + 'static,
547{
548    let header_text = if state.results.is_empty() {
549        format!("find · «{}» · sin matches", state.last_query)
550    } else {
551        format!(
552            "find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre",
553            state.last_query,
554            state.selected + 1,
555            state.results.len(),
556        )
557    };
558
559    let close_btn = View::new(Style {
560        size: Size { width: length(54.0_f32), height: length(18.0_f32) },
561        padding: Rect {
562            left: length(8.0_f32),
563            right: length(8.0_f32),
564            top: length(0.0_f32),
565            bottom: length(0.0_f32),
566        },
567        align_items: Some(AlignItems::Center),
568        flex_shrink: 0.0,
569        ..Default::default()
570    })
571    .fill(palette.bg_header)
572    .text_aligned("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center)
573    .on_click(to_host(FifMsg::CloseAll));
574
575    let header_label = View::new(Style {
576        flex_grow: 1.0,
577        size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
578        padding: Rect {
579            left: length(10.0_f32),
580            right: length(8.0_f32),
581            top: length(0.0_f32),
582            bottom: length(0.0_f32),
583        },
584        align_items: Some(AlignItems::Center),
585        ..Default::default()
586    })
587    .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
588
589    let header_bar = View::new(Style {
590        flex_direction: FlexDirection::Row,
591        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
592        align_items: Some(AlignItems::Center),
593        flex_shrink: 0.0,
594        ..Default::default()
595    })
596    .fill(palette.bg_header)
597    .children(vec![header_label, close_btn]);
598
599    let visible_start = state
600        .selected
601        .saturating_sub(MAX_VISIBLE.saturating_sub(1));
602    let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
603    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
604    for i in visible_start..visible_end {
605        let Some(fm) = state.results.get(i) else { continue };
606        let Some(path) = paths.get(fm.file_idx) else { continue };
607        let rel = relative_to(root, path);
608        let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
609        let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/');
610        let dir_label = if dir.is_empty() { String::new() } else { format!("  {dir}") };
611        let label = format!("{name}:{}{dir_label}    {}", fm.line + 1, fm.snippet);
612        let selected = i == state.selected;
613        let bg = if selected { palette.bg_selected } else { palette.bg_panel };
614        let fg = if selected { palette.fg_text } else { palette.fg_muted };
615        rows.push(
616            View::new(Style {
617                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
618                padding: Rect {
619                    left: length(12.0_f32),
620                    right: length(8.0_f32),
621                    top: length(0.0_f32),
622                    bottom: length(0.0_f32),
623                },
624                align_items: Some(AlignItems::Center),
625                flex_shrink: 0.0,
626                ..Default::default()
627            })
628            .fill(bg)
629            .text_aligned(label, 11.0, fg, Alignment::Start)
630            .on_click(to_host(FifMsg::ActivateAt(i))),
631        );
632    }
633
634    let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
635    children.push(header_bar);
636    children.extend(rows);
637
638    View::new(Style {
639        flex_direction: FlexDirection::Column,
640        size: Size { width: percent(1.0_f32), height: length(BAR_H) },
641        flex_shrink: 0.0,
642        ..Default::default()
643    })
644    .fill(palette.bg_panel)
645    .children(children)
646}
647
648/// Búsqueda substring case-insensitive. Pública para tests / hosts que
649/// quieran disparar una búsqueda sin pasar por el state machine.
650pub fn search(paths: &[PathBuf], query: &str) -> Vec<FifMatch> {
651    let mut out: Vec<FifMatch> = Vec::new();
652    let q_lc = query.to_lowercase();
653    for (file_idx, path) in paths.iter().enumerate() {
654        if out.len() >= MAX_RESULTS {
655            break;
656        }
657        if let Ok(meta) = std::fs::metadata(path) {
658            if meta.len() > MAX_FILE_SIZE {
659                continue;
660            }
661        }
662        let Ok(content) = std::fs::read_to_string(path) else { continue };
663        for (line_idx, line) in content.lines().enumerate() {
664            if out.len() >= MAX_RESULTS {
665                break;
666            }
667            let line_lc = line.to_ascii_lowercase();
668            let Some(byte_off) = line_lc.find(&q_lc) else { continue };
669            let col = line[..byte_off.min(line.len())].chars().count();
670            let trimmed = line.trim_start();
671            let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS {
672                trimmed.to_string()
673            } else {
674                let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect();
675                format!("{cut}…")
676            };
677            out.push(FifMatch { file_idx, line: line_idx, col, snippet });
678        }
679    }
680    out
681}
682
683/// Reemplazo case-insensitive sobre los archivos involucrados en
684/// `results`. Devuelve `(files_changed, replacements, failures)`.
685/// Lee cada archivo una sola vez, sustituye todas las apariciones de
686/// `query` por `replacement` (case-insensitive, preservando el resto), y
687/// escribe sólo si hubo cambios. No toca buffers en memoria del host —
688/// el host es responsable de recargar tabs si quiere ver los cambios.
689pub fn replace_all(
690    paths: &[PathBuf],
691    results: &[FifMatch],
692    query: &str,
693    replacement: &str,
694) -> (usize, usize, usize) {
695    if query.is_empty() {
696        return (0, 0, 0);
697    }
698    let mut touched: std::collections::BTreeSet<usize> =
699        std::collections::BTreeSet::new();
700    for fm in results {
701        touched.insert(fm.file_idx);
702    }
703    let mut files_changed = 0usize;
704    let mut total_replacements = 0usize;
705    let mut failures = 0usize;
706    let q_lc = query.to_lowercase();
707    for idx in touched {
708        let Some(path) = paths.get(idx) else { continue };
709        let Ok(content) = std::fs::read_to_string(path) else {
710            failures += 1;
711            continue;
712        };
713        let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement);
714        if n == 0 {
715            continue;
716        }
717        if std::fs::write(path, new_content).is_err() {
718            failures += 1;
719            continue;
720        }
721        files_changed += 1;
722        total_replacements += n;
723    }
724    (files_changed, total_replacements, failures)
725}
726
727/// Reemplazo case-insensitive preservando los bytes no-matchados.
728fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) {
729    let hay_lc = haystack.to_lowercase();
730    let mut out = String::with_capacity(haystack.len());
731    let mut count = 0usize;
732    let mut i = 0usize;
733    while i <= hay_lc.len() {
734        if let Some(pos) = hay_lc[i..].find(needle_lc) {
735            let abs = i + pos;
736            out.push_str(&haystack[i..abs]);
737            out.push_str(repl);
738            i = abs + needle_lc.len();
739            count += 1;
740        } else {
741            out.push_str(&haystack[i..]);
742            break;
743        }
744    }
745    (out, count)
746}
747
748// ---------------------------------------------------------------------
749// Helpers internos
750// ---------------------------------------------------------------------
751
752/// Pinta un input con etiqueta a la izquierda; cuando `focus` es true,
753/// el fondo se realza para que el user vea dónde está tipeando.
754fn labelled_input<HostMsg>(
755    label: &str,
756    state: &TextInputState,
757    placeholder: &str,
758    focus: bool,
759    palette: &FifPalette,
760    tp: &TextInputPalette,
761    fallback_msg: HostMsg,
762) -> View<HostMsg>
763where
764    HostMsg: Clone + 'static,
765{
766    let bg = if focus { palette.bg_selected } else { palette.bg_panel };
767    let label_view = View::new(Style {
768        size: Size { width: length(82.0_f32), height: length(28.0_f32) },
769        padding: Rect {
770            left: length(10.0_f32),
771            right: length(4.0_f32),
772            top: length(0.0_f32),
773            bottom: length(0.0_f32),
774        },
775        align_items: Some(AlignItems::Center),
776        flex_shrink: 0.0,
777        ..Default::default()
778    })
779    .text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start);
780
781    let input_view = View::new(Style {
782        flex_grow: 1.0,
783        size: Size { width: percent(0.0_f32), height: length(28.0_f32) },
784        padding: Rect {
785            left: length(4.0_f32),
786            right: length(10.0_f32),
787            top: length(2.0_f32),
788            bottom: length(2.0_f32),
789        },
790        ..Default::default()
791    })
792    .children(vec![text_input_view(
793        state,
794        placeholder,
795        focus,
796        tp,
797        fallback_msg,
798    )]);
799
800    View::new(Style {
801        flex_direction: FlexDirection::Row,
802        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
803        align_items: Some(AlignItems::Center),
804        flex_shrink: 0.0,
805        ..Default::default()
806    })
807    .fill(bg)
808    .children(vec![label_view, input_view])
809}
810
811fn relative_to(root: &Path, path: &Path) -> String {
812    path.strip_prefix(root)
813        .map(|p| p.display().to_string())
814        .unwrap_or_else(|_| path.display().to_string())
815}