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