#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use std::time::Duration;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
pub const CAPABILITIES: &[&str] = &["editor.find-in-files"];
pub const MAX_RESULTS: usize = 1000;
pub const MAX_FILE_SIZE: u64 = 2_000_000;
pub const SNIPPET_MAX_CHARS: usize = 160;
pub const MIN_QUERY_LEN: usize = 2;
const DIALOG_W: f32 = 560.0;
const DIALOG_H: f32 = 116.0;
const BAR_H: f32 = 220.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 9;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FifFocus {
Search,
Replace,
}
#[derive(Debug, Clone)]
pub struct FifMatch {
pub file_idx: usize,
pub line: usize,
pub col: usize,
pub snippet: String,
}
pub struct FifState {
pub input: TextInputState,
pub replace: TextInputState,
pub focus: FifFocus,
pub results: Vec<FifMatch>,
pub selected: usize,
pub last_query: String,
pub dialog_open: bool,
}
impl Default for FifState {
fn default() -> Self {
Self::new()
}
}
impl FifState {
pub fn new() -> Self {
Self {
input: TextInputState::new(),
replace: TextInputState::new(),
focus: FifFocus::Search,
results: Vec::new(),
selected: 0,
last_query: String::new(),
dialog_open: true,
}
}
}
#[derive(Clone)]
pub enum FifMsg {
Open,
CloseDialog,
CloseAll,
KeyInput(KeyEvent),
Nav(i32),
Submit,
ActivateAt(usize),
ToggleFocus,
ReplaceAll,
}
#[derive(Debug, Clone)]
pub enum FifAction {
None,
CloseDialog,
CloseAll,
Searched { matches: usize, elapsed: Duration, query: String },
OpenAt { path: PathBuf, line: usize, col: usize },
Replaced {
files_changed: usize,
replacements: usize,
failures: usize,
query: String,
replacement: String,
},
}
pub fn apply(state: &mut FifState, msg: FifMsg, paths: &[PathBuf]) -> FifAction {
match msg {
FifMsg::Open => {
state.dialog_open = true;
FifAction::None
}
FifMsg::CloseDialog => FifAction::CloseDialog,
FifMsg::CloseAll => FifAction::CloseAll,
FifMsg::KeyInput(ev) => {
let _ = match state.focus {
FifFocus::Search => state.input.apply_key(&ev),
FifFocus::Replace => state.replace.apply_key(&ev),
};
FifAction::None
}
FifMsg::ToggleFocus => {
state.focus = match state.focus {
FifFocus::Search => FifFocus::Replace,
FifFocus::Replace => FifFocus::Search,
};
FifAction::None
}
FifMsg::ReplaceAll => {
let query = state.last_query.clone();
if query.is_empty() || state.results.is_empty() {
return FifAction::None;
}
let replacement = state.replace.text();
let (files_changed, replacements, failures) =
replace_all(paths, &state.results, &query, &replacement);
state.results.clear();
state.selected = 0;
FifAction::Replaced {
files_changed,
replacements,
failures,
query,
replacement,
}
}
FifMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
FifAction::None
}
FifMsg::Submit => {
let query = state.input.text();
let needs_search = query != state.last_query || state.results.is_empty();
if needs_search {
if query.len() < MIN_QUERY_LEN {
return FifAction::None;
}
let started = std::time::Instant::now();
let results = search(paths, &query);
let elapsed = started.elapsed();
let n = results.len();
state.results = results;
state.selected = 0;
state.last_query = query.clone();
FifAction::Searched { matches: n, elapsed, query }
} else {
let Some(fm) = state.results.get(state.selected).cloned() else {
return FifAction::None;
};
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
FifMsg::ActivateAt(idx) => {
if idx >= state.results.len() {
return FifAction::None;
}
state.selected = idx;
let fm = state.results[idx].clone();
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
}
pub fn on_key(state: &FifState, event: &KeyEvent) -> Option<FifMsg> {
if !state.dialog_open {
return None;
}
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => FifMsg::CloseDialog,
Key::Named(NamedKey::Enter) => FifMsg::Submit,
Key::Named(NamedKey::Tab) => FifMsg::ToggleFocus,
Key::Named(NamedKey::ArrowDown) => FifMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => FifMsg::Nav(-1),
_ => FifMsg::KeyInput(event.clone()),
})
}
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f"))
}
#[derive(Debug, Clone)]
pub struct FifPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub border: Color,
theme: llimphi_theme::Theme,
}
impl FifPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
border: t.border,
theme: t.clone(),
}
}
}
pub fn view_dialog<HostMsg, F>(
state: &FifState,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let dirty_query = state.input.text() != state.last_query;
let header = if state.last_query.is_empty() {
"find in files · Enter busca · Esc cierra".to_string()
} else if state.results.is_empty() {
format!("«{}» · sin matches · Esc cierra", state.last_query)
} else {
let staleness = if dirty_query { " · Enter re-busca" } else { "" };
format!(
"«{}» · {} matches · ↓↑ navega · Enter abre{staleness} · Esc cierra",
state.last_query,
state.results.len(),
)
};
let header_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
let tp = TextInputPalette::from_theme(&palette.theme);
let search_focus = state.focus == FifFocus::Search;
let search_view = labelled_input(
"buscar",
&state.input,
"buscar en archivos…",
search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_view = labelled_input(
"reemplazar",
&state.replace,
"(vacío para borrar)",
!search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_btn = View::new(Style {
size: Size { width: length(118.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.radius(3.0)
.text_aligned(
"reemplazar todo".to_string(),
10.0,
palette.fg_muted,
Alignment::Center,
)
.on_click(to_host(FifMsg::ReplaceAll));
let hint = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start);
let actions = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![hint, replace_btn]);
let dialog = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: length(DIALOG_W), height: length(DIALOG_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.radius(6.0)
.children(vec![header_view, search_view, replace_view, actions]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) },
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(12.0_f32),
bottom: length(4.0_f32),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Start),
flex_shrink: 0.0,
..Default::default()
})
.children(vec![dialog])
}
pub fn view_results_bar<HostMsg, F>(
state: &FifState,
paths: &[PathBuf],
root: &Path,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let header_text = if state.results.is_empty() {
format!("find · «{}» · sin matches", state.last_query)
} else {
format!(
"find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre",
state.last_query,
state.selected + 1,
state.results.len(),
)
};
let close_btn = View::new(Style {
size: Size { width: length(54.0_f32), height: length(18.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center)
.on_click(to_host(FifMsg::CloseAll));
let header_label = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let header_bar = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.children(vec![header_label, close_btn]);
let visible_start = state
.selected
.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(fm) = state.results.get(i) else { continue };
let Some(path) = paths.get(fm.file_idx) else { continue };
let rel = relative_to(root, path);
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/');
let dir_label = if dir.is_empty() { String::new() } else { format!(" {dir}") };
let label = format!("{name}:{}{dir_label} {}", fm.line + 1, fm.snippet);
let selected = i == state.selected;
let bg = if selected { palette.bg_selected } else { palette.bg_panel };
let fg = if selected { palette.fg_text } else { palette.fg_muted };
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
padding: Rect {
left: length(12.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(label, 11.0, fg, Alignment::Start)
.on_click(to_host(FifMsg::ActivateAt(i))),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
children.push(header_bar);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
pub fn search(paths: &[PathBuf], query: &str) -> Vec<FifMatch> {
let mut out: Vec<FifMatch> = Vec::new();
let q_lc = query.to_lowercase();
for (file_idx, path) in paths.iter().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_FILE_SIZE {
continue;
}
}
let Ok(content) = std::fs::read_to_string(path) else { continue };
for (line_idx, line) in content.lines().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
let line_lc = line.to_ascii_lowercase();
let Some(byte_off) = line_lc.find(&q_lc) else { continue };
let col = line[..byte_off.min(line.len())].chars().count();
let trimmed = line.trim_start();
let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS {
trimmed.to_string()
} else {
let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect();
format!("{cut}…")
};
out.push(FifMatch { file_idx, line: line_idx, col, snippet });
}
}
out
}
pub fn replace_all(
paths: &[PathBuf],
results: &[FifMatch],
query: &str,
replacement: &str,
) -> (usize, usize, usize) {
if query.is_empty() {
return (0, 0, 0);
}
let mut touched: std::collections::BTreeSet<usize> =
std::collections::BTreeSet::new();
for fm in results {
touched.insert(fm.file_idx);
}
let mut files_changed = 0usize;
let mut total_replacements = 0usize;
let mut failures = 0usize;
let q_lc = query.to_lowercase();
for idx in touched {
let Some(path) = paths.get(idx) else { continue };
let Ok(content) = std::fs::read_to_string(path) else {
failures += 1;
continue;
};
let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement);
if n == 0 {
continue;
}
if std::fs::write(path, new_content).is_err() {
failures += 1;
continue;
}
files_changed += 1;
total_replacements += n;
}
(files_changed, total_replacements, failures)
}
fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) {
let hay_lc = haystack.to_lowercase();
let mut out = String::with_capacity(haystack.len());
let mut count = 0usize;
let mut i = 0usize;
while i <= hay_lc.len() {
if let Some(pos) = hay_lc[i..].find(needle_lc) {
let abs = i + pos;
out.push_str(&haystack[i..abs]);
out.push_str(repl);
i = abs + needle_lc.len();
count += 1;
} else {
out.push_str(&haystack[i..]);
break;
}
}
(out, count)
}
fn labelled_input<HostMsg>(
label: &str,
state: &TextInputState,
placeholder: &str,
focus: bool,
palette: &FifPalette,
tp: &TextInputPalette,
fallback_msg: HostMsg,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
let bg = if focus { palette.bg_selected } else { palette.bg_panel };
let label_view = View::new(Style {
size: Size { width: length(82.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(4.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start);
let input_view = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(4.0_f32),
right: length(10.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.children(vec![text_input_view(
state,
placeholder,
focus,
tp,
fallback_msg,
)]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.children(vec![label_view, input_view])
}
fn relative_to(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}