1#![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
101pub const CAPABILITIES: &[&str] = &["editor.find-in-files"];
106
107pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum FifFocus {
122 Search,
123 Replace,
124}
125
126#[derive(Debug, Clone)]
128pub struct FifMatch {
129 pub file_idx: usize,
133 pub line: usize,
135 pub col: usize,
137 pub snippet: String,
139}
140
141pub struct FifState {
143 pub input: TextInputState,
144 pub replace: TextInputState,
146 pub focus: FifFocus,
147 pub results: Vec<FifMatch>,
148 pub selected: usize,
149 pub last_query: String,
152 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#[derive(Clone)]
179pub enum FifMsg {
180 Open,
183 CloseDialog,
186 CloseAll,
188 KeyInput(KeyEvent),
190 Nav(i32),
192 Submit,
195 ActivateAt(usize),
197 ToggleFocus,
199 ReplaceAll,
204}
205
206#[derive(Debug, Clone)]
209pub enum FifAction {
210 None,
211 CloseDialog,
214 CloseAll,
216 Searched { matches: usize, elapsed: Duration, query: String },
218 OpenAt { path: PathBuf, line: usize, col: usize },
222 Replaced {
226 files_changed: usize,
227 replacements: usize,
228 failures: usize,
229 query: String,
230 replacement: String,
231 },
232}
233
234pub 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 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
327pub 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
346pub 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#[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: 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
382pub 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 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 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
531pub 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
648pub 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
683pub 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
727fn 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
748fn 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}